Metabase のダッシュボードをコードで管理する

2021-01-25

metastasis という gem を作ったのでその話です。
前提として比較的近しい状況にあり、画面をぽちぽちするより多少 hacky なことをしてでも手作業を減らすことに労力を使う方がマシだと思う人向けです。

Metabase について

Metabase はオープンソースのBIツールです。 様々なデータソースに簡単に接続でき、 GUI だけで分析を組み立てたり、SQL などのネイティブクエリを書くこともできます。 競合としては Re:dash などがありますが、使い勝手やアウトプットのビジュアルの良さから Metabase 推しの記事をよく見かけます。

社内でも採用しており、 Rails 製の管理画面から数値を確認するダッシュボード的な機能を分離するために使われています。 SaaS や Enterprise 版もありますが、自前の ECS で運用しているため、staging, production それぞれで独立した Metabase が存在するのが現在のスタイルです。

問題とその原因

先ほど述べた運用スタイルで GUI 経由で SQL を書いてダッシュボードに登録、という作業をやっていると、以下のような問題が出てきます。

  1. 書いたコードを反映するまでのプロセスにレビューを挟みにくい
  2. ダッシュボードを異なる環境でもう一度作らないといけない

1については、私たちは通常 GitHub(GitLab) などを用いて、PR(MR) ベースで開発しますが、独立したツールである Metabase 上で行われる直接的かつ即時保存可能な変更が、そのフローから外れてしまっていることが原因な気がします。

2については、そもそも今回のケースではインフラの段階から環境が別物という制約があること、そして Metabase にインポート / エクスポートの機能がないことが挙げられそうです。クエリそのものはコピペできますが、ダッシュボードの配置やフィルタの設定は手作業が必要です。

対策

DSL で Metabase のダッシュボードを記述して、それを元に Metabase 内部用の DB を直接書き換えるツールを作りました。

そして生まれたものが冒頭にリンクを置いた metastasis という RubyGem です。

ダッシュボードの内容をアプリケーション本体のコードと同様に Git で管理可能になり、他のメンバーにクエリの中身を確認してもらうことも容易になるはずです。 デプロイはコマンド1発で行われ、フィルタなどを Metabase 上でぽちぽち設定する必要もなくなります。

metastasis について

DB のスキーマ管理ツール ridgepole にインスパイアされていて、コード的にもかなり参考にしています。 metastasis という名前はコピーのイメージと Metabase にかけて meta が部分一致する単語の中から選んだのですが、医学の文脈における転移という意味なのであんまりよくなかったかもしれません。

ここでは Ruby 側のロジックというよりは、 Metabase がダッシュボードなどを格納しているテーブルについて簡単に説明します。

まず今回使う範囲で Metabase が内部に持っているのはこんな感じです。

# table name          description
metabase_table        # 接続しているデータソース中のテーブルのインデックス
metabase_field        # カラムのインデックス
report_dashboard      # ダッシュボードの定義
report_card           # クエリの定義
report_dashboardcard  # ダッシュボードへのレポートの登録を表す中間テーブル

さらに、 metastasis はデプロイ時に使う管理用のテーブルを同じ DB 上に作ります。

# table name    description
metastasis_tags # 定義ファイルの id と反映先のレコードの対応表

実装においてはこれらを ActiveRecord のモデルとして扱っています。

report_card は名前、クエリ本文、実行対象のデータベースなどのほかに表示形式(スカラー, グラフ, ...)や可視化する際の軸にどのカラムを使うかなどの情報を持っています。 ただし表示名や可視化の設定はダッシュボードに登録する際に上書きできるため、 report_dashboardcard にパラメータとして持たせることが多そうです。

metabase_table, metabase_field は、カードが可視化に使うカラムの指定に名前ではなくカラムの id を要求するため、 metabase_table の中からテーブル名で検索 → その下の metabase_field とカラム名で検索して id を得るという用途で使われています。 これもダッシュボード側の可視化設定を使う場合が多いです。

report_dashboard は入れ物なので、持っているのは名前程度です。 カードをどのダッシュボードのどこに並べるかの情報や、縦横のサイズなどを持っているのは report_dashboardcard です。

使い方

ダッシュボードの定義を管理するディレクトリの構成は次のようになります。 ディレクトリの名前は任意で、リポジトリのルートであればそもそも掘らなくても大丈夫なはずです。

./metastasis
  ├ config.yml # DB の接続情報など
  ├ Radiograph # 定義ファイルをまとめる
  └ queries/   # 定義ファイル
     ├ card1.query
     ├ dashboard1.query
     └ ...

以下に示す config.yml は Rails でいう config/database.yml とほぼ同じもので、環境ごとに DB の接続情報を書くことができます。 ここに設定するのは、 Metabase のバックエンドとして使っている DB の情報です。 query_config 以下は metastasis のグローバルなパラメータとして解釈されます。 ここでは development と staging でクエリの実行対象の DB に異なる id が振られているときのために全体のデフォルト値を設定しています。 後述する個別のクエリ定義でも設定できます。

# config.yml

default: &default
  adapter: postgresql
  encoding: unicode
  user: <%= ENV['POSTGRES_USER'] %>
  username: <%= ENV['POSTGRES_USER'] %>
  password: <%= ENV['POSTGRES_PASSWORD'] %>
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  port: 5432
  reaping_frequency: nil

development:
  <<: *default
  host: <%= ENV['POSTGRES_HOST'] %>
  database: metabase
  query_config:
    database_id: 1

staging:
  <<: *default
  host: <%= ENV['POSTGRES_HOST'] %>
  database: metabase
  query_config:
    database_id: 3

# 略

Radiograph は、定義ファイルの entry point となるファイルです。ファイル名はコマンドの実行時オプションで上書きできるので、好きな名前にして大丈夫です。 全ての定義はこのファイルを経由して読まれるので、拡張子やファイルの配置などに特に制限はありません。

# Radiograph

require './queries/card1.query'
require './queries/card2.query'


require './queries/dashboard1.query'

次にカードの定義です。register_card() を使って次のように書きます。 ここで与えている引数が、今後同じカードを編集したときにどれを上書きするのかの識別に使われるユニークな ID です。

register_card :unique_name_to_describe_card do |c|
  c.database_id 1         # 実行対象の DB
  c.collection_id 1       # コレクションの id
  c.name 'Reservations'   # 名前
  c.display :table        # 結果の表示方法 `:bar`, `:line`, `:scalar`, `:pie` などがある
  c.query_type 'native'   # 基本は native
  c.query <<~QUERY        # クエリ本体
    SELECT status, count(*)
    FROM reservations
    GROUP BY status
    [[ where DATE_TRUNC('day', created_at) = {{ apply_date }} ]]
  QUERY

  # パラメータ
  c.parameter :apply_date, type: 'date/single', display_name: 'Applied Date'
end

最後にダッシュボードの定義です。 ここではブロック内で定義するものに順序があり、 parameter > layout, visualize とします。 実際の Metabase ではダッシュボード自体にフィルタを追加したあと、そのフィルタをそれぞれのカードのパラメータに接続するようなイメージで組み立てるためです。

layout に与えている col, row, sizeX, sizeY あたりがカードの座標とサイズの情報です。 座標のインデックスは0始まりで、横方向は最大 18 grid という制限があります。 一応重複があるとライブラリレベルでエラーになりますが、現状ここは手で組み立てないといけません。

register_dashboard :unique_name_for_dashboard do |c|
  c.name 'Dashboard Name' # 名前

  # フィルタ
  c.parameter :date, slug: :date, type: 'date/single'

  # カードを配置

  # parameter として、ダッシュボード上のフィルタ と カード上のパラメータ のマッピングを与えることで接続される
  c.layout :unique_name_to_describe_card, col: 0, row: 0, sizeX: 10, sizeY: 8, parameter: [{ name: :date, target: :apply_date }]
  # 2次元のグラフ カードのクエリ内で select したカラムから、x,y それぞれの軸に使うものを与える
  c.visualize :unique_name_to_describe_card, card: { title: 'Card Name' }, graph: { dimensions: ['status'], metrics: ['count'] }
end

必要なだけの定義を書き終えたら、最後にコマンドを実行します。 例えば production 環境での接続先に向けてデプロイを実行する場合は次のようにします。

% metastasis apply -e production

何もエラーが起きずに終了すれば成功です。

パラメータ設定について

(202109追記) Metabase 本体の内部表現をまとめたドキュメントを作りました   metadocs - Unofficial Metabase internal representation cheat sheet

紹介したカードやダッシュボードの定義の中には様々なパラメータが登場しました。 しかし現状それらのパラメータのリストは Metabase の中にしかないため、新しい設定を表現するには一度本物のダッシュボードを作って DB を覗くのが手っ取り早い手段です。

いつかドキュメントとして整備したいのですが、ひとまずよく使うパターンをいくつかメモしておきます。

# カード

register_card :unique_name_to_describe_card do |c|
  # 省略

  # 何らかの ID などテキストでの絞り込み
  c.parameter :shop_id,   type: 'text', display_name: 'ShopID'

  # 日付
  c.parameter :from_date, type: 'date', display_name: 'From Date'
  c.parameter :to_date,   type: 'date', display_name: 'To Date'
end
# ダッシュボード

register_dashboard :capsule__sales_dashboard do |c|
  # 何らかの ID など
  c.parameter :shop_id,   slug: :shop_id,   type: 'category'

  # 日付
  # `date/single` の他に `date/range` などが存在する
  c.parameter :from_date, slug: :from_date, type: 'date/single'
  c.parameter :to_date,   slug: :to_date,   type: 'date/single'

  # 中略

  # 線グラフに対して、系統を追加、軸を指定 x軸にラベルを表示しない
  c.visualize :card_1, card: { title: 'タイトル' }, graph: { dimensions: ['created_at', 'some_column'], metrics: ['count'], 'x_axis.labels_enabled': false }
  # 線グラフに対して、軸を指定 ラベルはデフォルト
  c.visualize :card_2, card: { title: 'タイトル' }, graph: { dimensions: ['created_at'],                metrics: ['count'] }
  # scalar に対してはタイトルだけつける
  c.visualize :card_3, card: { title: 'タイトル' }
end

余談

元々は社内向けに作ったもので最初の commit は 2020/04 ごろ、ローカルに docker-compose で Metabase とデプロイを走らせる環境をつくって壊したりしながら書いていました。

ちなみに社内ではクエリを書くという作業自体がもともと属人化気味だったのは変化せず、ただデプロイが楽になるところまでで終わってしまいました。 あえてライブラリ独自のものを用意する必要がなさそうだったので、可視化などのパラメータは Metabase そのものの内部表現を使うことにした関係で無事オレオレ感が醸されてしまい、もう少しドキュメントを整備できればなーというところです。

結構後になって知った & いまだに使ったことないのですが Looker という有償の BI ツールはデフォルトで GitHub と連携できる模様。 プロジェクト単位でポコポコ建てる用途にはどうか分かりませんが、社内統一のデータ分析基盤みたいな他社の事例を調べると、これか Tableau の採用率が高かったです。

限定的なシーン向けではありますが、いつかどなたかの役に立てばいいなと思いながらここで筆を置くことにします。