サイトが20個ある。それぞれ GA4 のプロパティを持っている。ある日「全部合わせて何人来ているのか、積み上げの折れ線と円グラフで見たい」と思った。

GA4 の画面ではできない。標準版の GA4 にはプロパティをまたいだ集計機能が無い。 ロールアップ プロパティやサブプロパティは GA360(有料版)の機能だ。

だから、どこに置くにせよ「Data API で全プロパティを引いて、自分で足す」処理が要る。そこから設計が始まった。

置き場所: Cloudflare Worker ではなく自宅の Raspberry Pi

最初の候補は Cloudflare Worker だった。エッジで配れて速い。でも、やめた。理由は3つある。

1. 認証情報の置き場が無い。 GA4 を読む認証は、サービスアカウントの偽装(ADC)で開発機にしか置いていない。方針としてサービスアカウントの鍵ファイルは作らないことにしている。Worker から GA4 を直接叩くには鍵を作って Worker のシークレットに入れることになり、鍵の口を1つ増やす。

2. データを毎日入れ替える手段が高くつく。 Worker に静的データを載せるなら毎日デプロイが要る。デプロイは GitHub Actions で、その無料枠(月2,000分)はdeploy 用に温存している。毎日1回削られるのは困る。KV や R2 に置く案は、従量課金のデータストアを新規に採用しないという鉄則に反する。

3. 見る人が1人しかいない。 エッジで配る利点(低遅延・スケール)がまったく効かない。一方、自宅の Raspberry Pi 5 には Cloudflare Tunnel と Access(Google SSO)で守った Grafana が既に動いていて、同じ経路にページを1枚足すコストはゼロだ。

結論: Raspberry Pi の nginx が静的 HTML を配り、生成は開発機の日次バッチが行う。

生成を Raspberry Pi ではなく開発機でやる理由

Raspberry Pi で生成すれば「開発機が寝ていると古い」問題は消える。でも、それには GA4 の認証情報を Raspberry Pi にも置くことになる。

外に面している機械に置く秘密は最小にする、と決めてある。以前、費用チェックのバッチを移すときに「開発機のデプロイ用トークンを Raspberry Pi にコピーしてはいけない、専用の読み取り専用トークンを作る」と判断した。それと同じ理由だ。

代わりに、古さを隠さないことで受け入れる。ページの上部に必ず「更新: 9月16日 12:03」のように更新日時を出し、データが2日以上古いときは赤字で警告する。

もう一つ。開発機は自動サスペンド運用で、確実に起きるのは太陽光の発電ピークに合わせた 12:00 だけだ。タイマーに「08:45」と書いてあっても、それは「起きていればそこで走る」という意味で、寝ていればその日最初に起きたときに走る(実測: 08:20指定のジョブが10:07に走った日がある)。Persistent=true にしてあるので起床後に追いつく。実運用上は「その日開発機が最初に起きたとき、遅くとも12時台」と理解している。

貯める: 10年分の履歴を Google の外に持つ

ダッシュボードを作るついでに、もう一つ決めた。毎日 PostgreSQL に写す。

GA4 のイベント単位の保持期間は既定で2〜14か月で、探索レポートはその範囲でしか作れない。日次の集計値は今のところ2024年2月(最初のサイトを作った日)まで返るが、それは Google の運用方針であって、保証ではない。プロパティの消失、方針変更、API の廃止。どれが来ても独立していたい。

スキーマは4つ。

  • daily_metrics — 日 × サービスの合計(ユーザー数、セッション、PV、エンゲージメント)
  • daily_breakdown — 日 × サービス × 内訳(デバイス・国・言語・チャネル)
  • daily_pages — 日 × サービス × ページ
  • ingest_runs — 取り込みの記録

取り込みは既定で直近7日分を取る。GA4 は当日分が後から確定するので、前日だけを取ると取りこぼす。7日分を COPY で一時表に入れ、期間ぶんを削ってから入れ直す(冪等)。

罠1: order_bys を付けないと、最古の日が取れない

遡って取り込むとき、「このプロパティの最古の日はいつか」を知りたかった。limit=1 で日付を引いた。

GA4 は順序を指定しなければ、任意の1行を返す。 返ってきたのは2026年2月16日で、それを最古だと思い込んで、遡り取り込みを2026年2月1日から始めた。実際の最古は2024年2月20日だった。2年分を取りこぼすところだった。

最古の日を調べるときは order_bys を必ず付ける。当たり前のようだが、limit=1 が返す1行を「最初の1行」だと読んでしまう。SQL の LIMIT 1 に慣れていると、なおさら。

罠2: 1サービスが複数ホストを持つと、主キーが衝突する

blog は omikuji.dev と www.omikuji.dev と、移行前の *.pages.dev の3ホストで計測されている。ホスト別のまま入れると (day, service) の主キーが衝突する。

日ごとに合算してから入れる。 直近3日のテストでは各サービス1ホストだったので出ず、2年分の遡りで初めて踏んだ。テストの期間が短いと、古いデータにしか無い形を見逃す。

罠3: GA4 自身が (other) を返す

ディメンションの値が多すぎると、GA4 はカーディナリティ上限で裾を (other) にまとめて返す。こちらでも容量のために裾を丸めるのだが、同じ名前を作ると主キーが衝突する。既に (other) があれば足し込む。

上限を入れる: 1GB の VPS に10年分

PostgreSQL は1GB メモリの VPS に載っていて、7つのサービスが同居している。上限の無いテーブルを作らない。

  • 国・言語は1日20値まで。溢れた分は (other) に合算するので、合計は保たれる
  • ページは1日50件まで。裾は捨てる。全体の合計は daily_metrics を見る

これで10年分でも1GB 未満の見積りになる。ページの裾を捨てるのは惜しいが、「全部取っておく」は10年後に困る。

見せ方: 前日比ではなく「先週の同じ曜日」比

ダッシュボードには、横断の積み上げに加えて「1日にフォーカスするページ」と「曜日別のページ」を足した。

比較の基準を前日ではなく、先週の同じ曜日にした。アクセスの曜日差が大きくて(実測で土日は平日の4割前後)、日曜と月曜を並べても増減の意味が読めない。「先週の水曜より今週の水曜のほうが伸びている」なら、意味がある。

途中で前日比は全部外した。曜日によってアクセス数はほぼ決まっていて、必ず落ちる日と上がる日があるので、前日と比べても見る意味がない。同じ理由で直近90日の折れ線も外し、代わりにサービスごとの「同じ曜日の推移」を小さな棒で敷き詰めた。全体で伸びているだけでなく、このサービスもちゃんと増えている、が分かるように。

この2枚は GA4 を引かず、写しておいた PostgreSQL を読む。同じ数字は既にある。GA4 から引き直すと6〜9分かかり、毎朝の枠を二重に使う。認証情報が要らないので開発機以外でも動く。代償は「取り込みが止まると2枚とも古くなる」ことで、これは鮮度のメトリクスで見る。

数え方の限界

ページにも明記してある。

  • 合計は各プロパティの単純合計。プロパティをまたいだ名寄せはできないので、複数サービスを見た人は二重に数える
  • 積み上げグラフは「日ごとのユーザー数の足し算」、内訳は「期間全体での実人数」。GA4 側の数え方が違うので、両者の合計は一致しない

構成

  • プロパティは自動列挙する(アカウント配下を列挙して空のものを除く)。サイトを足したら GA4 のプロパティを作るだけで、ダッシュボード側の登録は要らない。表示名の対応表は「おまけ」で、入れ忘れても欠落はしない(英名で出るだけ)
  • 別の Google アカウントで計測しているサービスは、同じ認証情報から見えないので構造的に集計できない。これは対象外として割り切った
  • 取り込みの失敗は systemd の OnFailure で Slack へ。ただしこれが拾えるのは「走って落ちた」場合だけで、タイマーが止まっている・開発機が何日も寝ている、といった黙った停止は拾えない。最後の成功時刻をメトリクスに出して、48時間更新が無ければ鳴らす。この型については別に書いた

まとめ

  • GA4 の標準版はプロパティをまたげない。 Data API で引いて自分で足す
  • 置き場所は「誰が見るか」「認証情報をどこに置くか」「毎日の更新に何を使うか」で決まる。Worker はこの3つ全部で不利だった
  • 生成は秘密を置いてよい機械でやり、古さは隠さず表示する
  • order_bys を付けずに limit=1 で最古日を引かない。 任意の1行が返る
  • 複数ホストのサービスは日ごとに合算してから入れる。短期間のテストでは踏まない
  • 1GB の VPS に入れるなら上限の無いテーブルを作らない。裾は (other) に寄せて合計を守る
  • 比較は前日ではなく先週の同じ曜日。曜日差が4割あると、前日比は意味を持たない
  • 「走って落ちた」と「走らなかった」は別のアラートで見る

10年分を貯める、と決めた日の翌日に、2年分を取りこぼしかけた。貯める仕組みより、貯め始める前の1行(order_bys)のほうが効いた。