自分のサイトのアクセス解析に、自分の開発環境のアクセスが混ざっていたことがある。しかも数百ページ分。

犯人はE2Eテストだった。Playwrightが数百ページを巡回し、それが全部 localhost からのページビューとしてGoogleアナリティクスに記録されていた。毎朝レポートを読んでいるのに、その数字の一部が自分のテストだったわけだ。

対策は単純で、本番ホスト名のときだけ計測タグを読み込むようにすればいい。

const GA_HOST = "tools.omikuji.dev";

if (location.hostname === GA_HOST) {
  const s = document.createElement("script");
  s.async = true;
  s.src = "https://www.googletagmanager.com/gtag/js?id=" + GA_ID;
  document.head.appendChild(s);
  // ... gtag の初期化
}

これで解決した。ただし、同時に「このifの中身をローカルで一度も実行できない」という状態も作ってしまった。

import.meta.env.PROD では足りなかった

最初は Astro/Vite の import.meta.env.PROD で囲んでいた。開発サーバーでは false になるので、これで十分だと思っていた。

足りなかった。astro preview は本番ビルドをそのまま配信するので、PROD は true になる。E2Eの構成が astro build && astro preview になっているプロジェクトでは、ビルド済みの本番コードをローカルで走らせているわけで、当然タグが発火する。実際それで localhost のページビューがアナリティクスに入っていたサイトがあった。

だから判定は PROD ではなくホスト名にするのが確実だ、という結論になった。ここまでは正しい。

そして「ガードの先」がテストできなくなる

ホスト名で分岐するということは、ローカルでは常に偽の枝に入るということだ。

計測タグの読み込みだけなら、それで困らない。読み込まれないのが正しい挙動だからだ。困るのは、そのガードの先に他のロジックがぶら下がっている場合だ。

うちの場合、チャットのサイトでEU判定の同意ゲートを持っていた。構造としてはこうなる。

ホスト名が本番か?
  └─ はい → 地域を判定
        ├─ EU → 同意バナーを出す
        └─ 非EU → そのまま計測開始

同意バナーの出し分けを検証したい。でもローカルでは一段目で落ちるので、二段目以降のコードに到達する手段がない。

--host-resolver-rules で本番ホスト名を騙る、はずだった

Chromeには --host-resolver-rules という起動オプションがある。特定のホスト名の名前解決を、好きなアドレスに向けられる。

--host-resolver-rules="MAP tools.omikuji.dev 127.0.0.1:8787"

これでブラウザは tools.omikuji.dev を開いているつもりになり、location.hostname は本番ホスト名を返す。実体はローカルの wrangler dev だ。ガードを通過できる。完璧な作戦に見えた。

繋がらなかった。

原因: .dev はTLDごとHSTS preloadに入っている

HSTS(HTTP Strict Transport Security)は「このドメインには常にHTTPSで接続しろ」とブラウザに指示する仕組みだ。通常はサーバーが Strict-Transport-Security ヘッダを返して有効になる。

preloadリストというのがあって、これはブラウザにあらかじめ焼き込まれたHSTS対象ドメインの一覧だ。一度もアクセスしていないドメインでも、最初からHTTPSが強制される。

そして .dev はTLDまるごとこのpreloadリストに入っている。Googleが .dev を運用開始したときからそうなっている。つまり .dev で終わるホスト名は、すべて、例外なく、HTTPでは接続できない。

何が起きるか。

  1. http://tools.omikuji.dev:8787/ を開こうとする
  2. Chromeが preload リストを見て「.dev だからHTTPS必須」と判断する
  3. リクエストは https:// に強制昇格される
  4. ローカルの wrangler dev はHTTPで待っているので、繋がらない

--host-resolver-rules は名前解決を書き換えるオプションであって、スキームの昇格はその手前で起きる。名前解決までたどり着く前に潰されているので、この2つは噛み合わない。

しかもpreloadリストはブラウザにハードコードされているので、サイト側で無効化できない。通常のHSTSなら max-age=0 を返せば解除できるが、preloadは自分のドメインをリストから外す申請をGoogleに出す以外に手がない。当然そんなことはしない。

何が検証できて、何ができないのか

整理するとこうなる。

やりたいことローカルで可能か
ガードそのものの検査(ローカルでタグが読み込まれないこと)できる
ga-optout フラグの仕組みできる(後述)
ガードの先のロジック(地域判定・同意バナー)できない
本番ホスト名でのエンドツーエンドできない

「できない」ものは、素直に本番で確かめるしかない。これは敗北ではなく、切り分けの結論だ。無理にローカルで再現しようとして時間を溶かすより、境界をはっきりさせたほうがいい。

回避策1: 除外フラグをガードより前に置く

ひとつだけ設計で工夫できるところがある。検査したいものを、ホスト名ガードより前に出すことだ。

うちでは「自分のブラウザを計測から除外する」フラグを、ガードの前に置いている。

// ホスト名の判定より前に置く。そうすると dev / E2E でもこのフラグが立つことを検査できる
try {
  if (localStorage.getItem("ga-optout") === "1") {
    window["ga-disable-" + GA_ID] = true;
  }
} catch (e) {}

if (location.hostname === GA_HOST) {
  // ここから先はローカルでは絶対に通らない
}

window["ga-disable-<測定ID>"] はgtag標準の除外フラグで、これが立っているとタグが読み込まれても送信しない。これをガードの外に出しておけば、ローカルのE2Eでも「フラグが正しく立つか」をテストできる。各サイトにこのテストを置いてある。

順番を変えるだけで、テストできる面積が増える。ガードの内側に入れてしまうと、この検査も道連れで失えなくなる。

回避策2: 本番を触るときは先に除外フラグを立てる

そして本番での確認だ。ここに罠がもうひとつある。

ホスト名ガードは本番検証を素通りさせる。 当たり前だが、本番を実ブラウザで開けば location.hostname は本番ホスト名なので、計測タグは普通に動く。確認のたびに自分のアクセスが数字に乗る。

だから本番を開く前に、必ず除外フラグを立てる。

// DevTools で
localStorage.setItem("ga-optout", "1");

Playwrightで自動化するなら addInitScript で、ページのスクリプトより先に実行させる。

await context.addInitScript(() => {
  try { localStorage.setItem("ga-optout", "1"); } catch (e) {}
});

goto の後に evaluate で立てても手遅れだ。そのときにはもうタグが動いている。

ついでに: localhost と 127.0.0.1 は別のオリジン

HSTSとは別件だが、同じ領域でよくハマるので書いておく。

ブラウザにとって localhost と 127.0.0.1 は別のオリジンだ。localStorage も sessionStorage も共有されない。片方で立てたフラグはもう片方に無い。

テストのbaseURLとDevToolsで開くURLが違っていると、「フラグを立てたのに効かない」という現象が起きる。原因はフラグではなくオリジンの違いなので、まずそこを疑ってほしい。

まとめ

  • 計測タグの判定は import.meta.env.PROD ではなくホスト名で。astro preview は PROD が true になる
  • ただしホスト名でガードすると、その先のコードはローカルで一度も実行されなくなる
  • --host-resolver-rules で本番ホスト名をローカルに向ける手は、.dev では効かない。.dev はTLDごとHSTS preloadに入っており、HTTPが強制的にHTTPSに昇格されて wrangler dev に届かない。ブラウザ側の仕様なのでサイト側から無効化できない
  • 検査したいもの(除外フラグなど)はガードより前に置く。テストできる面積が変わる
  • ガードの先は本番で確かめる。そのときこそ先に ga-optout を立てる。ホスト名ガードは本番検証を素通りさせるので、これをしないと確認のたびに自分のアクセスが数字に乗る

「ローカルで再現できない」とき、たいていは環境の設定を疑う。でもときどき、原理的に再現できないことがある。.dev のHSTS preloadはその一例で、ブラウザの仕様なので回避策は無い。境界を知って、本番で確かめる側に回すほうが早い。