Skip to main content

5 posts tagged with "spike"

View All Tags

M0.3 — Google-native first-party mode check

· One min read

Status: blocked in this environment. Requires a live Vercel deployment and a real gtag-instrumented site to verify whether Google's own first-party mode for Google tags is usable on Vercel deployments. No deployed environment is available here.

What this blocks: scoping M3.1 (script-loading/proxying for gtag.js) around a supported mechanism instead of building parallel plumbing. Per the plan (1.7), this spike has no bearing on the Edge-vs-Node runtime choice for the collect handler — that's decided independently on latency/library-compatibility grounds, which US-007 does without needing this spike.

Default assumption taken in its absence: M3.1 (script proxying) stays out of v1 scope, as the plan already specifies — nothing in M1/M2 implementation depends on this spike's outcome.

To unblock: deploy apps/demo to a real Vercel project and test gtag('config', ..., { server_container_url: ... }) against it.

M0.1 — MP v2 fidelity spike

· 3 min read

Status: partially unblocked (2026-08-24) — live-verified against a real GA4 property.

Verified live against catesworks.dev (GA4 property 551179302, measurement ID G-YC0JKCQVXY), using the package's own buildMpPayload/collectUrl/debugCollectUrl shape sent directly at /debug/mp/collect and /mp/collect:

  • Real bug found and fixed: buildMpPayload sent both a manual session_id param and ga_session_id. GA4's live debug endpoint flagged this as NAME_DUPLICATED — GA4 canonicalizes ga_session_id to the same internal session field, so the redundant session_id collided with it instead of adding information. Fixed in src/server/mp-client.ts (only ga_session_id/ ga_session_number/engagement_time_msec are sent now); see mp-client.test.ts for the regression test and the package CHANGELOG for the release note.
  • Realtime visibility confirmed: after the fix, a POST /mp/collect with only ga_session_id/ga_session_number/engagement_time_msec (no session_start/first_visit, no gtag.js involved) returned 204 and the event (mp_relay_spike_verification) appeared in GA4's Realtime "Event count by Event name" report within ~30-60s, with "Active users in last 30 minutes" incrementing. This confirms the M0.1 finding (a) assumption baked into buildMpPayload was directionally correct, modulo the duplicate-param bug above.
  • ip_override (AC30): inconclusive, not a negative result. Two follow-up events sent with ip_override: "8.8.8.8" (Google Public DNS — a well-known non-residential/datacenter IP) never appeared in Realtime, despite both returning 204. The most likely explanation is GA4's bot/ spam filtering excluding traffic from a widely-recognized datacenter IP, not a failure of ip_override itself — real customer traffic comes from residential/mobile IPs, so this artifact is specific to the synthetic test IP chosen, not the relay's implementation. Re-running this check with a real residential/mobile IP (or accepting that this can only be confirmed from genuine end-user traffic post-launch) would close this out fully.
  • Device/browser dimension fidelity from a forwarded user-agent header alone: not conclusively checked in this pass — the Realtime "Tech" report wasn't reachable via direct URL navigation in the time available, and chasing this further had diminishing returns relative to the two confirmed findings above. Still open.

Still blocked / out of scope for a single pass: Traffic-acquisition and Engagement report settling can take up to 48h, which doesn't fit a single automated verification session. Whether synthesizing session_start/first_visit from the relay's session cookie would further close the fidelity gap vs. real sGTM is also still untested — the events sent in this spike were sent without those synthesized events, on purpose, to isolate the base MP v2 fidelity question first.

Default assumption from the original plan (still holds as the baseline): MP v2 has a fidelity gap vs. real sGTM (no automatic session_start/first_visit, no Google Signals, no automatic attribution) — accepted as a known, documented limitation of v1, not something implementation blocks on.

To fully close this spike: re-run the ip_override check from a real residential/mobile IP (or wait for genuine end-user traffic), check the Realtime Tech report for device/browser dimension population, and let a Traffic-acquisition/Engagement report settle for 48h+ on a property receiving steady synthetic or real traffic.

Playwright browser e2e — unblocked, all 3 ACs browser-confirmed

· 6 min read

Status: RESOLVED (2026-08-24). All three ACs this spike targeted (AC14, AC16, AC27) are now real-browser-confirmed. pnpm exec playwright test --project=chromium passes 4/4.

The original blocker — no Playwright browser binaries available — is resolved. pnpm exec playwright install chromium succeeded (Chrome for Testing 151.0.7922.34, /Volumes/dev-ssd/caches/playwright/chromium-1234). Firefox/webkit are present in the cache but at stale versions vs. what this Playwright wants, so chromium is the only project currently runnable here.

What was added

  • packages/ga4-relay/e2e/helpers.ts — shared helpers wrapping the real navigator.serviceWorker API (waitForRegistrationActive, getRegistrationSummaries, registerAndWaitForActive, unregisterAllServiceWorkers). No mocking — every call goes through page.evaluate into the actual browser API.
  • packages/ga4-relay/e2e/ac27-scope.spec.ts — AC27: registration.scope resolves to the configured nested /ga4-relay/ scope, both via ga4-init.tsx's real auto-registration and via an explicit navigator.serviceWorker.register() call.
  • packages/ga4-relay/e2e/ac14-coexistence.spec.ts — AC14: a trivial no-op host SW at root scope (apps/demo/public/test-sw.js) plus the resilience SW at its nested scope both remain registered and active, including after a reload.
  • packages/ga4-relay/e2e/ac16-kill-switch.spec.ts — AC16: real registration.unregister() on the resilience SW leaves the site loading/rendering with zero uncaught page errors.
  • apps/demo/public/test-sw.js — trivial no-op SW used only by the AC14 spec.
  • apps/demo/.env.local (gitignored) — placeholder GA4_*/UPSTASH_* values so the demo app can boot without real credentials.

Bug #1 (real product bug, fixed): SW route 500s in both dev and prod

GET /ga4-relay/ga4-sw.js returned 500 in both next dev and next build && next start. Root cause: apps/demo/app/ga4-relay/ga4-sw.js/route.ts's loadScriptSource() did createRequire(import.meta.url).resolve("@gtmss/ga4-relay/sw-script") — inside a Next.js Route Handler, webpack intercepts require.resolve and returns a bundler-internal identifier instead of a real filesystem path ("(rsc)/../../packages/ga4-relay/dist/sw/index.js" in dev, a numeric module id in prod), so readFile() on the result always threw. This affected every real consumer following the documented pattern, not just the demo.

Fix: moved script-loading inside the package itself. createServiceWorkerHandler's loadScriptSource stays a required, explicit parameter (unchanged signature), but the package now ships loadBundledSwScript from a new @gtmss/ga4-relay/server/sw-script-loader subpath — consumers pass that instead of hand-rolling their own loader:

import { createServiceWorkerHandler } from "@gtmss/ga4-relay/server";
import { loadBundledSwScript } from "@gtmss/ga4-relay/server/sw-script-loader";

export const GET = createServiceWorkerHandler({ scope: "/ga4-relay/", loadScriptSource: loadBundledSwScript });

loadBundledSwScript reads dist/sw/index.js relative to its own module's import.meta.url — resolved inside the package's own code (which next.config.mjs's serverExternalPackages keeps un-bundled), not inside app-bundled route-handler code, so it's never subject to the interception above. It's built as a separate tsup entry/output file from ./server (see tsup.config.ts) specifically so its node:fs import never lands in the same bundle as withGa4Token, which Edge middleware imports from ./server — confirmed live: without this split, a build produced A Node.js module is loaded ('url' at line 899) which is not supported in the Edge Runtime for apps/demo/middleware.ts.

A second, subtler bug surfaced fixing this: fileURLToPath(new URL("../sw/index.js", import.meta.url)) threw TypeError: The "path" argument must be of type string or an instance of URL. Received an instance of URL at runtime under Next's serverExternalPackages interop — a cross-realm instanceof URL mismatch between whatever constructs import.meta.url's URL-like value in that interop layer and Node's own fileURLToPath. Fixed by avoiding the URL class entirely: plain string manipulation on the file://-prefixed import.meta.url string (packages/ga4-relay/src/server/sw-script-loader.ts).

Verified live via direct curl against both next start (prod) and next dev: 200, content-type: text/javascript, cache-control: no-cache, service-worker-allowed: /ga4-relay/, real SW script bytes.

Bug #2 (test-environment artifact, not a product bug): dev-mode auto-reload race

With bug #1 fixed, next dev still flaked on the auto-registration specs (AC14, and AC27's auto-registration case). Live-instrumented (page.on("framenavigated")) reproduction showed an unexpected second navigation firing ~20-30ms after the initial load — Next.js dev mode's on-demand route compilation (the SW route hadn't been hit yet, so Next compiles it lazily, ~700ms) triggers a client-side reload the first time a not-yet-compiled route is requested. That reload raced the resilience SW's registration, non-deterministically resetting it mid-activation. A production build never does this. Fixed by pointing playwright.config.ts's webServer at a production build (next build && next start) instead of next dev — also the more correct target for validating real browser SW behavior, not dev-server hot-reload quirks.

One further flake remained even under production (isolated to the auto-registration spec, not reproducible via manual live instrumentation with register() call-counting, byte-identical script confirmation, or full updatefound/statechange event logging — root cause not conclusively isolated, possibly Chromium-internal SW bookkeeping settling around the same tick as the demo's own track() call firing a fetch to /api/ga4/collect). Hardened ac27-scope.spec.ts's assertion with expect.poll + a 250ms settle-and-reconfirm instead of a single instantaneous read — the correct way to assert "reaches a stable active state" for async browser state regardless. Stable across repeated runs since.

Also fixed while here: dev-mode double-registration hazard in the reference pattern

Unrelated to the two bugs above, but found live while investigating: React's App Router dev mode double-invokes effects, which would call Ga4Init's createGa4Client() (and thus registerServiceWorker()) twice per mount. Guarded with a module-level flag (survives the dev-mode remount, unlike a useRef) in both apps/demo/app/ga4-init.tsx and the root README's client usage example, since any real consumer copying that exact snippet would hit it too.

Net status vs. the original three ACs

  • AC27 (nested scope): browser-confirmed, both auto-registration and explicit register().
  • AC14 (coexistence): browser-confirmed — both SWs remain registered and active, including across a reload.
  • AC16 (kill-switch): browser-confirmed — unregister() leaves the site fully functional.

The SW beacon-capture spike remains a separate, still-open question (unload-time beacon capture) — not attempted in this pass, see that spike's own post.

M0.4 — Safari ITP cookie-longevity observation

· One min read

Status: blocked in this environment. Requires an 8+ day real-Safari observation against a deployed, genuinely same-origin site — not reproducible in a single automated pass, and no Safari/WebKit device with persistent state across days is available here.

What this blocks: empirically confirming the ADR's claim that a genuinely same-origin Set-Cookie is not subject to ITP's 7-day document.cookie cap (the cap applies to script-set/CNAME-cloaked cookies, not server-set same-origin ones, by ITP's own documented design).

Default assumption taken in its absence (per the plan's ADR): the cookie-longevity advantage over CNAME-based vendors is stated as mechanism-derived but not yet empirically validated — this qualifier stays in the ADR and in this repo until a real 8-day observation reports. Implementation is not blocked on this: cookies.ts (US-002) sets a standard Max-Age=63072000 server-side cookie regardless of the outcome, since that's the correct implementation either way — the spike only confirms how long it actually survives in Safari specifically.

To unblock: deploy apps/demo to a real same-origin domain and track the client_id cookie's survival in Safari over 8+ days.

M0.2 — SW beacon-capture spike

· 2 min read

Status: blocked in this environment. Requires launching real cross-browser instances (Chromium/Firefox/WebKit) with a live service worker and asserting whether the SW's fetch handler observes an outgoing fetch(keepalive)/sendBeacon call during page unload. This environment has no Playwright browser binaries installed and no ability to reliably simulate real unload timing across three engines in this pass.

What this blocks: deciding whether the service worker can be promoted from retry-only to a primary capture layer (Pre-mortem #1).

Default assumption taken in its absence (per plan Pre-mortem #1 and Requirements Summary): the SW's role defaults to retry-only — queued from the main thread on fetch/sendBeacon failure or offline detection, never assumed to reliably intercept unload-time beacons. This is the safe default and is what US-016 implements. Promotion to a capture role remains gated behind this spike passing, not assumed.

To unblock: install Playwright browser binaries (pnpm exec playwright install) and run the standing regression test referenced in the plan's Verification Steps once a target environment supports launching real browsers.

Update: Chromium browser binaries are now installed and launchable in this environment (see the Playwright browser e2e spike, updated in the same pass as this note). That spike's own AC14/AC16/AC27 e2e specs are a different question (SW registration/coexistence/kill-switch) and were not extended to cover beacon/fetch(keepalive) capture during unload — this spike's own "To unblock" steps (a dedicated beacon-capture regression test, ideally cross-browser) were not attempted here. Verdict below is unchanged.