🐟

Metabase のクエリを GitHub にバックアップしていく

2024/07/09に公開

はじめに

ほとんどの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プロジェクトによって開発されているオープンソースソフトウェアのデータ可視化ツールです。
https://www.metabase.com/

事前設定

APIキーの発行

  • Metabase管理者のページ → 認証 → APIキー → 「APIキーの作成」

GitHub Actions

GitHub ActionsはGitHubがサービスの一環として提供する、ワークフロー自動化サービスです。
https://github.co.jp/features/actions

事前設定

GitHub Actions でのシークレットの使用

  • MetabaseのキーとMetabaseのURLを登録


https://docs.github.com/ja/actions/security-guides/using-secrets-in-github-actions

Workflowの認証設定編集

  • The requested URL returned error: 403エラー対応

    • Workflow permissionsをRead and write permissionsに変更する。
    • Allow GitHub Actions to create and approve pull requestsの設定

https://zenn.dev/kmukmu/articles/475b0542d05fa3

詰まったところ

ワークフローの手動実行設定

  • 週一のタイマー実行のバックアップとテスト用の手動実行設定
name: Weekly Backup

on:
  schedule:
    - cron: "0 15 * * 5"  # 金曜日の15:00に実行(UTC時間)→土曜のJST0時
  workflow_dispatch:  # 手動トリガーの追加

https://docs.github.com/ja/actions/using-workflows/manually-running-a-workflow

変更があるときだけコミット

      - 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'

https://zenn.dev/snowcait/articles/903d86d668fcb7

Bash 実行のデバッグ情報出力

- run: |
    set -x

https://zenn.dev/snowcait/scraps/9d9c47dc4d0414

コミットするアカウント

  • github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>を使用する

https://github.com/actions/checkout/issues/13#issuecomment-724415212

構成ファイル

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_URL": "https://sample.metabaseapp.com",
  "METABASE_API_KEY": ""
}

おわりに

metabaseは結構コード管理できる方だなと感じた。ただ、ダッシュボードのデザインは、1つ1つそのページ開いてPDFエキスポートしないといけいないから手間でした。
コードからダッシュボードデザインまで全て、GitHubで管理できたらいいなーという思い。

参考

https://www.metabase.com/learn/administration/metabase-api
https://qiita.com/melty_go/items/4890727bbfb60b95301b
https://zenn.dev/hellorusk/articles/b54cffd43c2929
https://zenn.dev/shunpay/articles/59f6fea9a38126

Discussion