p4ni.

チュートリアル

Google の SDK を使わずに Cloudflare Worker から GA4 Data API を叩く

· 更新 · 読了まで約19分

目次

月に一度、GA4 から5つの数字と人気ページの一覧を取り出したくなります。ページビュー、アクティブユーザー数、総ユーザー数、セッション数、それと販売しているテーマへのクリック数。そのためにダッシュボードを開き、期間を合わせ、グラフから値を読む。数分のクリック作業の成果が、結局どこかに書き写す数字だけです。ならば JSON を返すエンドポイントを作って、月次のチェックにそれを読ませればいい。

素朴にやるならローカルのスクリプトにサービスアカウントの鍵を持たせる形になりますが、年に12回しか走らない用事のために秘密鍵をノート PC のディスクへ置きたくはありません。Cloudflare Worker はここにきれいに収まります。鍵はシークレットの中だけに存在し、それを読めるのは Worker だけで、こちらは URL を1本手に入れる。

きれいに収まらないのが認証です。Google のクライアントライブラリは Node を前提にしていて、Workers は Node ではありません。動かしているのは workerd という別のランタイムです。

SDK は Workers に乗らない

普通ならこの役目は google-auth-library が担います。単体で入れるとこうなります。

$ npm i google-auth-library
added 23 packages
$ du -sh node_modules
 12M	node_modules

JWT を1つ署名するために 12MB と 23 パッケージ。Workers ではバンドルサイズが実際の制約になりますが、それより手前でもっと硬い問題にぶつかります。esbuild に workerd 条件を渡して束ねようとすると止まります。

$ npx esbuild entry.mjs --bundle --format=esm --conditions=workerd,worker,browser
✘ [ERROR] Could not resolve "stream"
    node_modules/gaxios/build/cjs/src/gaxios.js:24:25
✘ [ERROR] Could not resolve "crypto"
    node_modules/gaxios/build/cjs/src/gaxios.js:26:80
...
6 of 70 errors shown

解決できない import が70件。どれも Node の組み込みモジュールですが、出どころは HTTP 層だけではありません。27件は google-auth-library 自身が fsoschild_process を参照している分で、11件は JWT に署名するための jwsjwa、残りが node-fetch とプロキシ用の agent です。nodejs_compat を有効にすれば多くはポリフィルで埋まるので、押し通せる可能性はあります。ただ、その前にこのライブラリに何をさせたかったのかを考え直しました。

必要なのは1つだけです。サービスアカウントの鍵からアクセストークンを作ること。この交換はドキュメント化された HTTP のやり取りで、暗号が要るのは RS256 の署名1か所。そして crypto.subtle は RS256 に対応しています。だからこの Worker の依存はゼロになりました。

認証の実体は、署名1回と POST 1回

JWT bearer grant の流れはこうです。

  1. JSON のヘッダーと、サービスアカウント・スコープ・トークンエンドポイント(audience)を書いたクレームを組み立てる
  2. base64url(ヘッダー).base64url(クレーム) を秘密鍵で RS256 署名する
  3. できた JWT を assertion として https://oauth2.googleapis.com/token に POST する(grant_typeurn:ietf:params:oauth:grant-type:jwt-bearer)
  4. 1時間有効なアクセストークンが返る。あとは API に bearer として渡すだけ

署名しているのは「私はこのサービスアカウントで、このスコープのトークンが欲しい」という主張です。秘密鍵はその証明にあたります。

Web Crypto で JWT に署名する

作業の大半は2つの変換が占めます。crypto.subtle.importKeypkcs8 の鍵を ArrayBuffer で要求しますが、サービスアカウント JSON に入っているのは PEM の文字列です。ヘッダー行とフッター行と改行が付いた base64。そして JWT が使うのは base64url で、btoa はそれを吐きません。

const SCOPE = 'https://www.googleapis.com/auth/analytics.readonly';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';

function b64url(bytes) {
  let s = typeof bytes === 'string' ? bytes : String.fromCharCode(...new Uint8Array(bytes));
  return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function pemToDer(pem) {
  const body = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
  const bin = atob(body);
  const der = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) der[i] = bin.charCodeAt(i);
  return der.buffer;
}

async function getAccessToken(sa) {
  const now = Math.floor(Date.now() / 1000);
  const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const claims = b64url(
    JSON.stringify({
      iss: sa.client_email,
      scope: SCOPE,
      aud: TOKEN_URL,
      iat: now,
      exp: now + 3600,
    })
  );
  const key = await crypto.subtle.importKey(
    'pkcs8',
    pemToDer(sa.private_key),
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const sig = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    key,
    new TextEncoder().encode(`${header}.${claims}`)
  );
  const jwt = `${header}.${claims}.${b64url(sig)}`;

  const res = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion: jwt,
    }),
  });
  if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
  return (await res.json()).access_token;
}

このコードには、それぞれ半日を溶かしうる箇所が3つあります。

RSASSA-PKCS1-v1_5 が RS256 です。 JWT のアルゴリズム名と Web Crypto のアルゴリズム名は一致しません。しかも間違え方がたちの悪い方向に転びます。RSA-PSS も RSA 鍵と SHA-256 で署名し、長さも同じ署名を返すので、手元では何も起きません。おかしくなるのはトークンエンドポイントが検証できない assertion を突き返す瞬間で、そのエラーはどのアルゴリズムを期待していたのかを教えてくれません。

b64url のスプレッドが安全なのは、ここだけです。 String.fromCharCode(...bytes) はバイトを引数として全部渡すので、引数の個数には処理系の上限があります。2048ビット鍵の RS256 署名は 256 バイトなので上限までは遠い。ただしこのヘルパーをレスポンスボディの base64 化に流用すると、大きな入力で落ちます。

exp は JWT の寿命であって、トークンの寿命ではありません。 1時間が Google の受け付ける上限で、assertion は交換した時点で使い切りです。返ってくるトークンのほうが、別に1時間の有効期限を持ちます。

鍵は JSON まるごと1つのシークレットに入れる

フィールドを分けず、サービスアカウント JSON をそのまま1つのシークレットにします。

wrangler secret put GA4_SA_KEY < service-account.json

Worker 側では JSON.parse(env.GA4_SA_KEY) で受けます。この形にする理由は private_key にあります。JSON の中では改行が \n のエスケープとして1行に収まっていて、JSON.parse がそれを本物の改行に戻してくれる。PEM だけを取り出して別のシークレットに貼ろうとすると、複数行のテキストを CLI のプロンプト越しに手で扱うことになります。改行が壊れて pemToDer が例外を投げ始めるのは、たいていそこです。

登録したらローカルの JSON は消します。Worker のシークレットは書き込み専用で、上書きも削除もできる一方、Cloudflare 側から中身を読み出す手段がありません。ノート PC のファイルより安全と言えるのは、この性質があるからです。鍵を失くしたら GCP で新しく発行するだけで、復旧するものは何もありません。

鍵を作っただけでは 403 が返る

サービスアカウントと鍵を作っただけでは、まだ何もできません。あと2つあります。どちらを忘れても、バグにしか見えない 403 が返ってきます。

1つは GCP プロジェクトで Google Analytics Data API を有効化すること。サービスアカウントはプロジェクトが有効にした API しか呼べません。

もう1つは サービスアカウントのメールアドレスを、GA4 プロパティ側のユーザーとして追加することです。引っかかるのはこちらです。GA4 のプロパティへのアクセス権は GCP ではなくアナリティクス側で管理されているので、資格情報としては何の問題もない鍵が、プロパティの存在すら知らない状態になります。なんとか@プロジェクト名.iam.gserviceaccount.com をプロパティのアクセス管理画面に貼って初めて通ります。レポートを読める最小のロールは閲覧者で、この用途にはそれが適切です。私は「マーケティング担当者」を付けてしまいましたが、読み取り専用のエンドポイントには過剰でした。

runReport を呼ぶ

トークンさえあれば、Data API はごく普通の JSON over HTTP です。

const API = 'https://analyticsdata.googleapis.com/v1beta';

async function runReport(token, property, body) {
  const res = await fetch(`${API}/${property}:runReport`, {
    method: 'POST',
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`runReport failed: ${res.status} ${await res.text()}`);
  return res.json();
}

ここでは1つの GA4 プロパティを全サブドメインで共用しています。このブログと、テーマのデモである Almanac が同じプロパティに入る。だからどのレポートもホスト名で絞らないと数字の意味が変わります。私は hostNamedimensionFilterdimensions の両方に入れています。ドキュメントのサンプルも、リクエストに含めていない dimension をフィルタに使っています。API の制約ではありません。生のレスポンスを見たときに、どのホストに絞れているのかがその場でわかるようにしただけです。

const hostFilter = {
  dimensionFilter: {
    filter: { fieldName: 'hostName', stringFilter: { value: 'astro.p4ni.com' } },
  },
};

const totals = await runReport(token, property, {
  dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }],
  dimensions: [{ name: 'hostName' }], // 出力に要るわけではなく、絞れているのを見るため
  metrics: [
    { name: 'screenPageViews' },
    { name: 'activeUsers' },
    { name: 'totalUsers' },
    { name: 'sessions' },
  ],
  ...hostFilter,
});

ただし dimension を足せば普通は行が分かれるので、合計を取りたいレポートには本来いちばん困る操作です。ここで害が出ないのは、フィルタが値を1つに絞っているからにすぎません。ホストが1つなら行も1つ。だから全ホストを見る host=all では、フィルタと dimension を一緒に外します。フィルタだけを外すとサブドメインごとの行が返り、その先頭行を合計だと思って読むことになります。

エンドポイントが走らせるレポートは3つ(合計・人気ページ・gumroad_click イベント数)で、互いに依存しないのでまとめて投げます。

const [totals, pages, gumroad] = await Promise.all([...]);

日付の扱いは API 側が楽にしてくれます。startDateendDateYYYY-MM-DD のほかに 30daysAgoyesterdaytoday を受け付けるので、Worker はクエリパラメータをそのまま素通しにできて、日付のパースを1行も書かずに済みました。

エンドポイントに鍵をかける

workers.dev のサブドメインは公開されていて、この Worker は見つけた人に私の解析データを返します。なので何より先に、自前の bearer トークンを検査します。

const auth = request.headers.get('authorization') || '';
if (!env.AUTH_TOKEN || auth !== `Bearer ${env.AUTH_TOKEN}`) {
  return new Response('unauthorized', { status: 401 });
}

比較そのものと同じくらい !env.AUTH_TOKEN の側が効いています。これが無いと、シークレットを設定し忘れたままデプロイしたときに '''Bearer ' を突き合わせることになる。設定漏れが「壊れたエンドポイント」ではなく「誰でも読めるエンドポイント」になる経路です。閉じるほうへ倒します。

$ curl -s -o /dev/null -w "%{http_code}\n" https://<worker>.workers.dev/
401
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer nope" https://<worker>.workers.dev/
401

返ってくるもの

{
  "range": { "start": "2026-07-01", "end": "today" },
  "host": "astro.p4ni.com",
  "pageViews": 18,
  "activeUsers": 5,
  "totalUsers": 5,
  "sessions": 5,
  "gumroadClicks": 1,
  "topPages": [
    { "path": "/", "views": 9 },
    { "path": "/themes/", "views": 3 },
    { "path": "/about/", "views": 1 },
    { "path": "/blog/deploy-astro-to-cloudflare-workers/", "views": 1 }
  ]
}

公開から3日のブログの実数です。そしてこの規模でもエンドポイントを作る価値があるのは、まさにこの数字のせいでもあります。呼ぶコストがゼロなら、月次のチェックは 18 だろうと 18,000 だろうと同じように読みに行く。

1つ注意すると、gumroad_click はカスタムイベントです。Data API は存在しないイベント名に対しても平然と 0 を返すので、数字が横ばいになったときは「誰も押していない」と結論する前にイベント名を確かめたほうがいい。

同じことは計測される側でも起こります。アナリティクスを動かすには CSP のディレクティブが3つ要ります。gtag.js を読む script-src、ビーコンを送る connect-src、ピクセルにフォールバックしたときの img-src最初の1つだけを許可したポリシーは、画面が完全に正常なまま計測だけを壊します。

やっていないこと

トークンをキャッシュしていません。 リクエストのたびに Data API を叩く前のトークン交換をやり直しているので、1回の呼び出しに 1.3〜1.8 秒かかります。アクセストークンの有効期限は1時間で、こちらが呼ぶのは月1回。年12回しか起きないことを最適化する話になるので、そのままにしてあります。更新のあるダッシュボードから叩くなら、Cache API が安い解です。ただしこれは Worker をカスタムドメインに載せている場合の話で、workers.dev のままでは caches.default への put が何も残しません。キャッシュはゾーンに属していて、workers.dev にはそのゾーンが無いからです。

// example.com のところは自分の管理下にあるドメインに置き換える
const cacheKey = new Request('https://cache.example.com/ga4-token');
const cache = caches.default;
let token = await cache.match(cacheKey).then((r) => r?.text());
if (!token) {
  token = await getAccessToken(JSON.parse(env.GA4_SA_KEY));
  await cache.put(
    cacheKey,
    new Response(token, { headers: { 'cache-control': 'max-age=3000' } })
  );
}

max-age=3000 にしてあるのは、有効期限の 3600 に対して10分の余裕を残すためです。期限ぎりぎりでキャッシュから出てきたトークンが飛行中に切れるのを避けられます。別のデータセンターに当たってキャッシュを外したときは、余分なトークン交換が1回走るだけです。ただしキャッシュキーには気を遣ってください。ここで置いているのは資格情報で、置き場所はこの Worker の専有ではありません。自分の管理下にあるホスト名を使い、キャッシュしたトークンは同じゾーンの他のコードから読まれうるものとして扱います。

レート制限は無く、トークンは1本です。 ローテーションの予定も無い固定の bearer が1つあるだけ。読者が私しかおらず、URL も誰も知らないエンドポイントなので、ここで意図して手を止めてあります。とはいえ全体の安全性がこの1つの文字列に乗っているのは確かなので、書いておきます。

まとめ

Google の認証ライブラリは workerd 向けにバンドルできません。12MB という話にたどり着く前に、解決できない Node の import が70件出ます。その下にある流れは、署名1回と POST 1回です。

手こずったのは暗号より権限のほうです。GCP で作った鍵は、それだけではプロパティを読めません。プロジェクト側で Data API を有効化し、そのうえで GA4 のアクセス管理にサービスアカウントのメールアドレスを閲覧者として貼る。どちらを忘れても返ってくるのは同じ 403 で、どちらが足りないのかは書いてありません。

RS256 は Web Crypto では RSASSA-PKCS1-v1_5 + SHA-256 です。RSA-PSS も同じくらい正しく見えて、返ってくるのは理由を言わないエラーになります。

サービスアカウント JSON はまるごと1つのシークレットに入れて JSON.parse します。PEM の改行が保たれるのはそのおかげです。

そして自分の認証ヘッダーは何より先に検査します。シークレットが未設定のときも通してしまわないよう、その場合は無条件で 401 を返します。