自分のサイトのアクセス解析に、自分の開発環境のアクセスが混ざっていたことがある。しかも数百ページ分。
犯人は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では接続できない。
何が起きるか。
http://tools.omikuji.dev:8787/を開こうとする- Chromeが preload リストを見て「
.devだからHTTPS必須」と判断する - リクエストは
https://に強制昇格される - ローカルの
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はその一例で、ブラウザの仕様なので回避策は無い。境界を知って、本番で確かめる側に回すほうが早い。