🐟
Metabase のクエリを GitHub にバックアップしていく
はじめに
ほとんどのBIツールには、GUIを使用して構築する性質上、コード管理機能がありません。MetabaseはAPIを通じてさまざまなデータを取得できるため、GUIで構築されたダッシュボードのデザインなどの項目以外の部分でコード管理が可能です。
今回は、GitHub Actionsを用いて、データ取得のコード管理を目指します。
あと、Metabaseのクエリをdbtと同じリポジトリ内のバックアップフォルダに格納することにより、関連クエリを探しやすいメリットがあるかも
初めて GitHub Actionsを構築したので詰まった部分も記載します。
リポジトリ構成
your-repo/
├── .github/
│ ├── workflows/
│ │ ├── metabase_weekly_backup.yml # GitHub Actionsの設定ファイル
│ │ └── README.md # プロジェクトの説明
│ └── script/
│ ├── metabase_backup_script.py # APIからデータを取得するPythonスクリプト
│ └── requirements.txt # Python依存パッケージをリスト
└── metabase_backup/ # データ保存用ディレクトリ(metabase_backupより下は自動生成)
├── json_backup/ # jsonデータ保存用ディレクトリ
│ └── data.json # 取得したデータ
└── query_backup/ # sqlデータ保存用ディレクトリ(コレクションと同様に構成される)
└── data.sql # 取得したデータ
Metabase
Metabaseとは、Metabaseプロジェクトによって開発されているオープンソースソフトウェアのデータ可視化ツールです。
事前設定
APIキーの発行
- Metabase管理者のページ → 認証 → APIキー → 「APIキーの作成」
GitHub Actions
GitHub ActionsはGitHubがサービスの一環として提供する、ワークフロー自動化サービスです。
事前設定
GitHub Actions でのシークレットの使用
- MetabaseのキーとMetabaseのURLを登録
Workflowの認証設定編集
-
The requested URL returned error: 403エラー対応
- Workflow permissionsをRead and write permissionsに変更する。
- Allow GitHub Actions to create and approve pull requestsの設定
詰まったところ
ワークフローの手動実行設定
- 週一のタイマー実行のバックアップとテスト用の手動実行設定
name: Weekly Backup
on:
schedule:
- cron: "0 15 * * 5" # 金曜日の15:00に実行(UTC時間)→土曜のJST0時
workflow_dispatch: # 手動トリガーの追加
変更があるときだけコミット
- name: Diff
id: diff
run: |
git add -N .
git diff --name-only --exit-code
continue-on-error: true
- name: Commit & Push
run: |
set -x
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git add .
git commit --author=. -m 'generated'
git push
if: steps.diff.outcome == 'failure'
Bash 実行のデバッグ情報出力
- run: |
set -x
コミットするアカウント
- github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>を使用する
構成ファイル
metabase_weekly_backup.yml
metabase_weekly_backup.yml
name: Metabase Weekly Backup
run-name: Run by @${{ github.actor }} - ${{ github.workflow }}
on:
schedule:
- cron: "0 15 * * 5" # 金曜日の15:00に実行(UTC時間)→土曜のJST0時
workflow_dispatch: # 手動トリガーの追加
jobs:
request_and_merge:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip' # caching pip dependencies
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r .github/script/requirements.txt
- name: Run Python script and save data
env:
METABASE_API_KEY: ${{ secrets.METABASE_API_KEY }}
METABASE_URL: ${{ secrets.METABASE_URL }}
run: | # スクリプトをエラーハンドリングモードにする(set -e )
set -e
python .github/script/metabase_backup_script.py
- name: Diff
id: diff
run: |
set -x
git add -N .
git diff --name-only --exit-code
continue-on-error: true
- name: Get current date
id: date
run: |
echo "JST_DATE=$(TZ=Asia/Tokyo date +'%Y%m%d')" >> $GITHUB_ENV
- name: Get changed files
id: changed-files
if: steps.diff.outcome == 'failure'
run: |
echo "FILES<<EOF" >> $GITHUB_OUTPUT
# Process git diff output and format it
git -c core.quotepath=false diff --name-status | LC_ALL=C.UTF-8 sort -u | while read status file; do
# Remove leading whitespace and format status
status="[${status}]"
file=$(echo "$file" | sed 's/^[[:space:]]*//')
echo "$status$file" >> $GITHUB_OUTPUT
done
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
commit-message: 'update Metabase Data: ${{ env.JST_DATE }}'
branch: ${{ env.JST_DATE }}_update_metabase_data
title: 'update metabase data: ${{ env.JST_DATE }}'
body: |
## 概要
このプルリクエストは、Metabaseの最新データと過去データとの差分を確認し、更新内容を把握することを目的としています。
また、Metabaseのデータバックアップとしても機能します。
## 変更されたファイル
- [ファイルのステータス](https://git-scm.com/docs/git-status/2.43.0#_short_format)(A = 新規追加、M = 変更、D = 削除)
```
${{ steps.changed-files.outputs.FILES }}
```
## 関連リンク
- [Metabaseダッシュボード](${{ secrets.METABASE_URL }})
- [Metabase API documentation](https://www.metabase.com/docs/latest/api-documentation)
if: steps.diff.outcome == 'failure'
requirements.txt
requests
pandas
metabase_backup_script.py
とりあえず動くことを目指て雑に書いたものを、chatgptにリファクタとdocstringとログ出力書いてもらうの便利すぎて、、、
metabase_backup_script.py
import requests
import pandas as pd
import json
import os
import shutil
def main():
"""
メイン関数。Metabaseからデータを取得し、バックアップを作成します。
環境変数からAPI keyとURLを取得し、指定されたドメインのデータをJSON形式で保存します。
また、カードデータからSQLクエリを抽出し、ファイルに保存します。
"""
try:
print("Starting Metabase backup process...")
api_key = get_env_variable("METABASE_API_KEY")
url = get_env_variable("METABASE_URL")
print(f"API URL: {url}")
base_folder = "metabase"
json_folder = os.path.join(base_folder, "json_backup")
query_folder = os.path.join(base_folder, "query_backup")
print(f"Backup folders set up: {json_folder}, {query_folder}")
print("Cleaning up backup folders...")
clear_folder(json_folder)
clear_folder(query_folder)
domain_list = [
"collection",
"dashboard",
"database",
"search",
"segment",
"setting",
"card",
"user",
"permissions_graph",
"permissions_group",
"permissions_membership",
]
print(f"Domains to fetch: {', '.join(domain_list)}")
get_json(domain_list, json_folder, api_key, url)
print("Importing card and collection data...")
card_data = import_data("card", json_folder, api_key, url)
collection_data = import_data("collection", json_folder, api_key, url)
print("Preparing collection dataframe...")
df = prepare_collection_dataframe(collection_data)
print("Exporting SQL queries...")
export_sql(card_data, query_folder, df)
print("Data successfully fetched and saved.")
except requests.exceptions.RequestException as e:
print(f"An error occurred while making the API request: {e}")
except json.JSONDecodeError as e:
print(f"An error occurred while decoding the JSON response: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
def get_env_variable(var_name):
"""
指定された名前の環境変数の値を取得します。
:param var_name: 取得する環境変数の名前
:return: 環境変数の値
:raises ValueError: 環境変数が設定されていない場合
"""
value = os.getenv(var_name)
if not value:
raise ValueError(f"{var_name} is not set in the environment variables.")
return value
def get_json(domain_list, json_folder, api_key, url):
"""
指定されたドメインリストのデータをMetabase APIから取得し、JSONファイルとして保存します。
:param domain_list: 取得するドメインのリスト
:param json_folder: JSONファイルを保存するフォルダのパス
:param api_key: Metabase API key
:param url: Metabase URL
"""
headers = {"x-api-key": api_key}
for domain in domain_list:
# アンダースコアで文字列を分割
split_parts = domain.split('_')
api_path = os.path.join(url, "api" ,*split_parts)
response = requests.get(api_path, headers=headers)
print(f"{domain} response.status_code : {response.status_code}")
if response.status_code == 200:
data = response.json()
save_json_to_file(data, os.path.join(json_folder, domain + ".json"))
def save_json_to_file(data, file_path):
"""
JSONデータをファイルに保存します。
:param data: 保存するJSONデータ
:param file_path: 保存先のファイルパス
"""
if not os.path.exists(os.path.dirname(file_path)):
os.makedirs(os.path.dirname(file_path))
with open(file_path, "w") as json_file:
json.dump(
data,
json_file,
ensure_ascii=False,
indent=4,
sort_keys=True,
separators=(",", ": "),
)
def import_data(domain, json_folder, api_key, url):
"""
指定されたドメインのデータをインポートします。
APIに接続するか、既存のJSONファイルから読み込むかを選択できます。
:param domain: インポートするドメイン
:param json_folder: JSONファイルが保存されているフォルダのパス
:param api_key: Metabase API key
:param url: Metabase URL
:return: インポートされたデータ
"""
file_path = os.path.join(json_folder, domain + ".json")
headers = {"x-api-key": api_key}
api_path = os.path.join(url, "api" ,domain)
response = requests.get(api_path, headers=headers)
print(f"import_data({domain}) response.status_code : {response.status_code}")
if response.status_code == 200:
data = response.json()
response.raise_for_status()
save_json_to_file(data, file_path)
return data
def prepare_collection_dataframe(collection_data):
"""
コレクションデータからDataFrameを作成し、パス情報を追加します。
:param collection_data: コレクションデータ
:return: 処理済みのDataFrame
"""
df = pd.json_normalize(collection_data)
df = df[["id", "location", "name"]].copy()
df["path"] = df.apply(create_path, axis=1, df=df)
return df
def create_path(row, df):
"""
与えられた行のパスを作成します。
:param row: 処理する行
:param df: 参照するDataFrame
:return: 作成されたパス
"""
folder_path = ""
if isinstance(row["location"], str) and row["location"] not in ["/", ""]:
folders = row["location"].strip("/").split("/")
for folder in folders:
folder = int(folder)
if (df["id"] == folder).any():
folder_name = df.loc[df["id"] == folder, "name"].values[0]
folder_path = os.path.join(folder_path, folder_name)
folder_path = os.path.join(folder_path, row["name"])
else:
folder_path = row["name"]
return folder_path
def export_sql(card_data, query_folder, df):
"""
カードデータからSQLクエリを抽出し、ファイルに保存します。
:param card_data: カードデータ
:param query_folder: クエリを保存するフォルダのパス
:param df: コレクション情報を含むDataFrame
"""
for data in card_data:
if (
"dataset_query" in data
and "native" in data["dataset_query"]
and "query" in data["dataset_query"]["native"]
):
sql_query = data["dataset_query"]["native"]["query"]
sql_name = data["name"].replace("/", "_") + ".sql"
collection_id = data["collection_id"]
folder_path = df[df["id"] == collection_id]["path"].values[0]
file_path = os.path.join(query_folder, folder_path, sql_name)
save_sql_to_file(sql_query, file_path)
def save_sql_to_file(query, file_path):
"""
SQLクエリをファイルに保存します。
:param query: 保存するSQLクエリ
:param file_path: 保存先のファイルパス
"""
if not os.path.exists(os.path.dirname(file_path)):
os.makedirs(os.path.dirname(file_path))
with open(file_path, "w") as sql_file:
sql_file.write(query)
def clear_folder(folder_path):
"""
フォルダ内のすべてのファイルを削除します。
:param folder_path: フォルダのパス
"""
if os.path.exists(folder_path):
for filename in os.listdir(folder_path):
file_path = os.path.join(folder_path, filename)
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path)
elif os.path.isdir(file_path):
shutil.rmtree(file_path)
if __name__ == "__main__":
main()
ローカルで動作確認する時のキーの扱い(参考までに)
def get_env_variable(var_name):
"""
指定された名前の環境変数の値を取得します。
:param var_name: 取得する環境変数の名前
:return: 環境変数の値
:raises ValueError: 環境変数が設定されていない場合
"""
with open("settings.json", "r") as f:
app_setting = json.load(f)
value = app_setting[var_name]
if not value:
raise ValueError(f"{var_name} is not set in the environment variables.")
return value
- settings.json
- Metabase のドメインを
"METABASE_URL"
に設定 - Metabase の APIキーを
"METABASE_API_KEY"
に設定
- Metabase のドメインを
{
"METABASE_URL": "https://sample.metabaseapp.com",
"METABASE_API_KEY": ""
}
おわりに
metabaseは結構コード管理できる方だなと感じた。ただ、ダッシュボードのデザインは、1つ1つそのページ開いてPDFエキスポートしないといけいないから手間でした。
コードからダッシュボードデザインまで全て、GitHubで管理できたらいいなーという思い。
参考
Discussion