サイトが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)のほうが効いた。