@tokenoftrust/storefront-runner 2.2.76 → 2.2.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/lib/cspStaticHashes.ts +1 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/native-amplitude.ts +31 -11
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +28 -13
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +3 -1
- package/packages/public-runtime/src/integration/native-ga4.ts +6 -1
- package/scripts/dev/ga4-gs2-canary.mjs +130 -0
|
@@ -19,7 +19,7 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
|
|
|
19
19
|
"'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
|
|
20
20
|
"'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
|
|
21
21
|
"'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
|
|
22
|
-
"'sha256-
|
|
22
|
+
"'sha256-ZxqYN/PbpT0W68E87+IwdGfcYJuAVXNPNVxVb3VX0hw='",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
export const STATIC_STYLE_HASHES: readonly string[] = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.77",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -74,6 +74,8 @@ export const NO_SESSION_ID = -1 as const;
|
|
|
74
74
|
export const VISITOR_CONTINUITY_STORAGE_KEY = "tot.analytics-continuity.v1" as const;
|
|
75
75
|
export const VISITOR_ID_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000;
|
|
76
76
|
export const SESSION_INACTIVITY_MS = 30 * 60 * 1000;
|
|
77
|
+
/** Ceiling on the per-visitor session count; a 90-day id cannot plausibly reach it. */
|
|
78
|
+
export const MAX_VISITOR_SESSION_COUNT = 100_000;
|
|
77
79
|
export const AMPLITUDE_HTTP_EVENT_TYPES = Object.freeze([
|
|
78
80
|
"storefront.page.viewed.v1",
|
|
79
81
|
"storefront.page.viewed.v2",
|
|
@@ -184,6 +186,8 @@ export interface VisitorContinuityIds {
|
|
|
184
186
|
deviceId: string;
|
|
185
187
|
sessionId: number;
|
|
186
188
|
sessionState: VisitorSessionState;
|
|
189
|
+
/** 1-based count of this visitor's sessions, current one included (GA4's session number). */
|
|
190
|
+
sessionCount: number;
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
export interface VisitorContinuity {
|
|
@@ -200,14 +204,17 @@ export interface CreateVisitorContinuityOptions {
|
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
interface VisitorRecord {
|
|
203
|
-
schemaVersion:
|
|
207
|
+
schemaVersion: 2;
|
|
204
208
|
deviceId: string;
|
|
205
209
|
createdAt: number;
|
|
206
210
|
sessionStartedAt: number;
|
|
207
211
|
lastActivityAt: number;
|
|
212
|
+
sessionCount: number;
|
|
208
213
|
}
|
|
209
214
|
|
|
210
|
-
const VISITOR_RECORD_KEYS = "createdAt,deviceId,lastActivityAt,schemaVersion,sessionStartedAt";
|
|
215
|
+
const VISITOR_RECORD_KEYS = "createdAt,deviceId,lastActivityAt,schemaVersion,sessionCount,sessionStartedAt";
|
|
216
|
+
/** The exact schemaVersion-1 shape, written before the session number existed; upgraded in place. */
|
|
217
|
+
const VISITOR_RECORD_V1_KEYS = "createdAt,deviceId,lastActivityAt,schemaVersion,sessionStartedAt";
|
|
211
218
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
212
219
|
|
|
213
220
|
export function visitorContinuityStorageKey(scope: VisitorContinuityScope): string {
|
|
@@ -215,22 +222,33 @@ export function visitorContinuityStorageKey(scope: VisitorContinuityScope): stri
|
|
|
215
222
|
return `${VISITOR_CONTINUITY_STORAGE_KEY}:${encodeURIComponent(contextKey)}`;
|
|
216
223
|
}
|
|
217
224
|
|
|
218
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* A stored record that is malformed, from the future, or past its fixed lifetime is not a
|
|
227
|
+
* visitor. A valid record in the exact schemaVersion-1 shape keeps its id and session and
|
|
228
|
+
* becomes schemaVersion 2 at session number 1 (the caller writes it back); every other
|
|
229
|
+
* shape is still refused, so an upgraded visitor is not counted twice.
|
|
230
|
+
*/
|
|
219
231
|
function liveVisitorRecord(raw: string | null, now: number): VisitorRecord | null {
|
|
220
232
|
if (!raw) return null;
|
|
221
233
|
let parsed: unknown;
|
|
222
234
|
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
223
|
-
if (!isPlainObject(parsed)
|
|
235
|
+
if (!isPlainObject(parsed)) return null;
|
|
236
|
+
const keys = Object.keys(parsed).sort().join(",");
|
|
224
237
|
const { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt } = parsed;
|
|
238
|
+
let sessionCount = parsed["sessionCount"];
|
|
239
|
+
if (keys === VISITOR_RECORD_V1_KEYS && schemaVersion === 1) sessionCount = 1;
|
|
240
|
+
else if (keys !== VISITOR_RECORD_KEYS || schemaVersion !== 2) return null;
|
|
225
241
|
if (
|
|
226
|
-
|
|
242
|
+
typeof deviceId !== "string" || !UUID_PATTERN.test(deviceId) ||
|
|
243
|
+
typeof sessionCount !== "number" || !Number.isSafeInteger(sessionCount) ||
|
|
244
|
+
sessionCount < 1 || sessionCount > MAX_VISITOR_SESSION_COUNT ||
|
|
227
245
|
typeof createdAt !== "number" || typeof sessionStartedAt !== "number" ||
|
|
228
246
|
typeof lastActivityAt !== "number" ||
|
|
229
247
|
!Number.isFinite(createdAt) || !Number.isFinite(sessionStartedAt) || !Number.isFinite(lastActivityAt) ||
|
|
230
248
|
createdAt > now || now - createdAt >= VISITOR_ID_LIFETIME_MS ||
|
|
231
249
|
sessionStartedAt < createdAt || lastActivityAt < sessionStartedAt
|
|
232
250
|
) return null;
|
|
233
|
-
return { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt };
|
|
251
|
+
return { schemaVersion: 2, deviceId, createdAt, sessionStartedAt, lastActivityAt, sessionCount };
|
|
234
252
|
}
|
|
235
253
|
|
|
236
254
|
function defaultRandomUUID(): string {
|
|
@@ -274,14 +292,15 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
|
|
|
274
292
|
if (!record) {
|
|
275
293
|
const deviceId = mint();
|
|
276
294
|
if (deviceId) {
|
|
277
|
-
record = { schemaVersion:
|
|
295
|
+
record = { schemaVersion: 2, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now, sessionCount: 1 };
|
|
278
296
|
started = "first_visit";
|
|
279
297
|
}
|
|
280
298
|
} else {
|
|
281
299
|
const rolled = now - record.lastActivityAt > SESSION_INACTIVITY_MS;
|
|
282
300
|
const sessionStartedAt = rolled ? now : record.sessionStartedAt;
|
|
283
301
|
if (rolled) started = "new_session";
|
|
284
|
-
|
|
302
|
+
const sessionCount = rolled ? Math.min(record.sessionCount + 1, MAX_VISITOR_SESSION_COUNT) : record.sessionCount;
|
|
303
|
+
record = { ...record, sessionStartedAt, sessionCount, lastActivityAt: Math.max(now, record.lastActivityAt) };
|
|
285
304
|
}
|
|
286
305
|
if (record) {
|
|
287
306
|
store.setItem(key, JSON.stringify(record));
|
|
@@ -291,6 +310,7 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
|
|
|
291
310
|
deviceId: record.deviceId,
|
|
292
311
|
sessionId: record.sessionStartedAt,
|
|
293
312
|
sessionState: stateOf(key, record.sessionStartedAt),
|
|
313
|
+
sessionCount: record.sessionCount,
|
|
294
314
|
};
|
|
295
315
|
}
|
|
296
316
|
} catch {
|
|
@@ -300,11 +320,11 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
|
|
|
300
320
|
let deviceId = memory.get(key);
|
|
301
321
|
if (!deviceId) {
|
|
302
322
|
const minted = mint();
|
|
303
|
-
if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
|
|
323
|
+
if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
|
|
304
324
|
deviceId = minted;
|
|
305
325
|
memory.set(key, deviceId);
|
|
306
326
|
}
|
|
307
|
-
return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
|
|
327
|
+
return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
|
|
308
328
|
},
|
|
309
329
|
clear(scope) {
|
|
310
330
|
const key = visitorContinuityStorageKey(scope);
|
|
@@ -479,7 +499,7 @@ function createAmplitudeHandler(input: AmplitudeHandlerInput): (event: CommerceE
|
|
|
479
499
|
if (!mapped || input.dedupe.seenOrRemember(mapped.insert_id)) return;
|
|
480
500
|
let identity: VisitorContinuityIds;
|
|
481
501
|
try { identity = input.continuity.readOrMint(scope, mapped.time); }
|
|
482
|
-
catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" }; }
|
|
502
|
+
catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 }; }
|
|
483
503
|
const body = JSON.stringify({
|
|
484
504
|
api_key: input.destinationId,
|
|
485
505
|
events: [{ ...mapped, device_id: identity.deviceId, session_id: identity.sessionId }],
|
|
@@ -12,6 +12,8 @@ const CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
|
12
12
|
const GOVERNED_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
13
13
|
const CLIENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
14
14
|
const SESSION_STATES: ReadonlySet<unknown> = new Set(["first_visit", "new_session", "continuing"]);
|
|
15
|
+
/** Ceiling on the session number the parent may claim (its own record caps at the same value). */
|
|
16
|
+
const MAX_SESSION_COUNT = 100_000;
|
|
15
17
|
/** gtag's own session-inactivity window, in seconds. */
|
|
16
18
|
const GTAG_SESSION_TIMEOUT_S = 30 * 60;
|
|
17
19
|
const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
|
|
@@ -65,27 +67,37 @@ function validGovernedProperties(value: unknown): value is Record<string, string
|
|
|
65
67
|
|
|
66
68
|
interface FrameVisitor {
|
|
67
69
|
clientId: string;
|
|
68
|
-
session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing" };
|
|
70
|
+
session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing"; count: number };
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
|
-
* The visitor crosses the same hostile boundary: a lowercase UUID and, together or
|
|
73
|
-
* at all, a positive integer epoch-ms session start
|
|
74
|
-
*
|
|
74
|
+
* The visitor crosses the same hostile boundary: a lowercase UUID and, all together or
|
|
75
|
+
* not at all, a positive integer epoch-ms session start, its closed state, and a capped
|
|
76
|
+
* positive session number (1 exactly on a first visit, at least 2 on a new session).
|
|
77
|
+
* Anything else refuses the whole configure rather than falling back to an id gtag would
|
|
78
|
+
* invent.
|
|
75
79
|
*/
|
|
76
80
|
function parseVisitor(value: unknown): FrameVisitor | null {
|
|
77
81
|
const record = plainRecord(value);
|
|
78
|
-
if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState"], ["clientId"])) return null;
|
|
79
|
-
const { clientId, sessionId, sessionState } = record;
|
|
82
|
+
if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState", "sessionCount"], ["clientId"])) return null;
|
|
83
|
+
const { clientId, sessionId, sessionState, sessionCount } = record;
|
|
80
84
|
if (typeof clientId !== "string" || !CLIENT_ID.test(clientId)) return null;
|
|
81
|
-
if (sessionId === undefined && sessionState === undefined) return { clientId };
|
|
85
|
+
if (sessionId === undefined && sessionState === undefined && sessionCount === undefined) return { clientId };
|
|
82
86
|
if (
|
|
83
87
|
typeof sessionId !== "number" || !Number.isSafeInteger(sessionId) || sessionId <= 0 ||
|
|
84
|
-
!SESSION_STATES.has(sessionState)
|
|
88
|
+
!SESSION_STATES.has(sessionState) ||
|
|
89
|
+
typeof sessionCount !== "number" || !Number.isSafeInteger(sessionCount) ||
|
|
90
|
+
sessionCount < 1 || sessionCount > MAX_SESSION_COUNT ||
|
|
91
|
+
(sessionState === "first_visit" && sessionCount !== 1) ||
|
|
92
|
+
(sessionState === "new_session" && sessionCount < 2)
|
|
85
93
|
) return null;
|
|
86
94
|
return {
|
|
87
95
|
clientId,
|
|
88
|
-
session: {
|
|
96
|
+
session: {
|
|
97
|
+
idSeconds: Math.floor(sessionId / 1000),
|
|
98
|
+
state: sessionState as "first_visit" | "new_session" | "continuing",
|
|
99
|
+
count: sessionCount,
|
|
100
|
+
},
|
|
89
101
|
};
|
|
90
102
|
}
|
|
91
103
|
|
|
@@ -122,9 +134,11 @@ function installMemoryCookieJar(doc: Document): Map<string, string> {
|
|
|
122
134
|
|
|
123
135
|
/**
|
|
124
136
|
* Seed gtag's session cookie so it reports the parent's session: `continuing` gets a
|
|
125
|
-
* live session (no `session_start`, no `first_visit`)
|
|
126
|
-
*
|
|
127
|
-
*
|
|
137
|
+
* live session (no `session_start`, no `first_visit`) at session number `count`;
|
|
138
|
+
* `new_session` gets an expired one at `count - 1`, which gtag increments as it starts
|
|
139
|
+
* the session for a returning visitor; `first_visit` gets none (gtag reports 1). The
|
|
140
|
+
* GS2 layout is gtag's own and undocumented (`o` is its session counter, measured
|
|
141
|
+
* 2026-10-06); if gtag stops parsing it, collection still carries the parent's
|
|
128
142
|
* `client_id` and session id and only the new-session / first-visit flags overcount.
|
|
129
143
|
*/
|
|
130
144
|
function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], visitor: FrameVisitor | null): void {
|
|
@@ -134,8 +148,9 @@ function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], vi
|
|
|
134
148
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
135
149
|
const lastSeen = session.state === "continuing" ? nowSeconds : session.idSeconds - GTAG_SESSION_TIMEOUT_S - 1;
|
|
136
150
|
const sessionStart = session.state === "continuing" ? session.idSeconds : lastSeen;
|
|
151
|
+
const sessionNumber = session.state === "continuing" ? session.count : session.count - 1;
|
|
137
152
|
for (const id of ids) {
|
|
138
|
-
jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$
|
|
153
|
+
jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$o${sessionNumber}$g0$t${lastSeen}$j0$l0$h0`);
|
|
139
154
|
}
|
|
140
155
|
}
|
|
141
156
|
|
|
@@ -28,9 +28,11 @@ export interface NativeGa4SandboxConfig {
|
|
|
28
28
|
export interface NativeGa4SandboxVisitor {
|
|
29
29
|
/** Lowercase random UUID, the GA4 `client_id`. */
|
|
30
30
|
clientId: string;
|
|
31
|
-
/** Session start in epoch ms; absent (with
|
|
31
|
+
/** Session start in epoch ms; absent (with the other session fields) when origin storage is unavailable. */
|
|
32
32
|
sessionId?: number;
|
|
33
33
|
sessionState?: "first_visit" | "new_session" | "continuing";
|
|
34
|
+
/** 1-based session number, GA4's `sct`. */
|
|
35
|
+
sessionCount?: number;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export interface NativeGa4PageViewCommand {
|
|
@@ -161,7 +161,12 @@ function frameVisitor(continuity: VisitorContinuity, scope: VisitorContinuitySco
|
|
|
161
161
|
if (ids.sessionId === NO_SESSION_ID || !Number.isSafeInteger(ids.sessionId) || ids.sessionId <= 0) {
|
|
162
162
|
return { clientId: ids.deviceId };
|
|
163
163
|
}
|
|
164
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
clientId: ids.deviceId,
|
|
166
|
+
sessionId: ids.sessionId,
|
|
167
|
+
sessionState: ids.sessionState,
|
|
168
|
+
sessionCount: ids.sessionCount,
|
|
169
|
+
};
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
function hasAnalyticsConsent(ctx: IntegrationLifecycleContext): boolean {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ga4-gs2-canary.mjs — does gtag.js still read the GA4 sandbox's seeded session cookie?
|
|
3
|
+
//
|
|
4
|
+
// The sandbox frame seeds gtag's `_ga_<container>` cookie (Google's undocumented GS2 format)
|
|
5
|
+
// into an in-memory jar so GA4 reports the parent's visitor and session. This loads the BUILT
|
|
6
|
+
// sandbox document and the CURRENT live gtag.js in headless Chromium, drives three page loads
|
|
7
|
+
// (first visit, continuing, new session) through the real bind/configure protocol, and asserts
|
|
8
|
+
// what gtag puts on /g/collect. Every collect request is ABORTED: nothing reaches Google.
|
|
9
|
+
//
|
|
10
|
+
// Needs the network (gtag.js), so it is a canary, not a CI test. Exit 0 = gtag behaves as the
|
|
11
|
+
// seed expects; 1 = drift (the docs say what GA4 then reports); 2 = could not run.
|
|
12
|
+
//
|
|
13
|
+
// Usage: node scripts/dev/ga4-gs2-canary.mjs
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
|
|
16
|
+
const storefrontRequire = createRequire(new URL("../../apps/storefront/package.json", import.meta.url));
|
|
17
|
+
let chromium;
|
|
18
|
+
let buildGoogleMeasurementSandboxHtml;
|
|
19
|
+
try {
|
|
20
|
+
({ chromium } = storefrontRequire("@playwright/test"));
|
|
21
|
+
({ buildGoogleMeasurementSandboxHtml } = await import("../../apps/storefront/integrations/google-measurement-sandbox-asset.mjs"));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(`ga4-gs2-canary: cannot run — ${error instanceof Error ? error.message : String(error)}`);
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PARENT = "https://shop.canary.test";
|
|
28
|
+
const FRAME = "https://www.shop.canary.test/platform/google-measurement/v2/sandbox.html";
|
|
29
|
+
const MEASUREMENT_ID = "G-CANARY0001";
|
|
30
|
+
const CLIENT_ID = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d";
|
|
31
|
+
const FIRST_SESSION_MS = Date.now() - 5 * 60 * 1000;
|
|
32
|
+
const SECOND_SESSION_MS = Date.now();
|
|
33
|
+
const seconds = (ms) => String(Math.floor(ms / 1000));
|
|
34
|
+
|
|
35
|
+
const LOADS = [
|
|
36
|
+
{
|
|
37
|
+
name: "first visit",
|
|
38
|
+
visitor: { clientId: CLIENT_ID, sessionId: FIRST_SESSION_MS, sessionState: "first_visit", sessionCount: 1 },
|
|
39
|
+
expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: "1", _fv: "1" },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "next page, same session",
|
|
43
|
+
visitor: { clientId: CLIENT_ID, sessionId: FIRST_SESSION_MS, sessionState: "continuing", sessionCount: 1 },
|
|
44
|
+
expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: null, _fv: null },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "return, new session",
|
|
48
|
+
visitor: { clientId: CLIENT_ID, sessionId: SECOND_SESSION_MS, sessionState: "new_session", sessionCount: 2 },
|
|
49
|
+
expect: { cid: CLIENT_ID, sid: seconds(SECOND_SESSION_MS), sct: "2", _ss: "1", _fv: null },
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const parentPage = (visitor) => `<!doctype html><body><script>
|
|
54
|
+
const frame = document.createElement("iframe");
|
|
55
|
+
frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
|
56
|
+
frame.src = ${JSON.stringify(FRAME)};
|
|
57
|
+
frame.addEventListener("load", () => {
|
|
58
|
+
const channel = new MessageChannel();
|
|
59
|
+
frame.contentWindow.postMessage({ protocol: "tot.ga4-sandbox/1", type: "bind", parentOrigin: location.origin }, new URL(frame.src).origin, [channel.port2]);
|
|
60
|
+
channel.port1.postMessage({ schemaVersion: 1, type: "configure", destinationIds: [${JSON.stringify(MEASUREMENT_ID)}],
|
|
61
|
+
canonicalOrigin: location.origin, analyticsConsent: "granted",
|
|
62
|
+
governedProperties: { tenant: "canary", environment: "live", site_surface: "storefront" },
|
|
63
|
+
visitor: ${JSON.stringify(visitor)} });
|
|
64
|
+
channel.port1.postMessage({ schemaVersion: 1, type: "page_view", path: "/canary" });
|
|
65
|
+
});
|
|
66
|
+
document.body.append(frame);
|
|
67
|
+
</script></body>`;
|
|
68
|
+
|
|
69
|
+
const html = await buildGoogleMeasurementSandboxHtml();
|
|
70
|
+
const browser = await chromium.launch();
|
|
71
|
+
const failures = [];
|
|
72
|
+
let gtagLoaded = false;
|
|
73
|
+
try {
|
|
74
|
+
const context = await browser.newContext();
|
|
75
|
+
const pageViews = new Map();
|
|
76
|
+
let current = 0;
|
|
77
|
+
await context.route("**/*", async (route) => {
|
|
78
|
+
const request = route.request();
|
|
79
|
+
const url = new URL(request.url());
|
|
80
|
+
if (request.url().startsWith(FRAME)) return route.fulfill({ status: 200, contentType: "text/html", body: html });
|
|
81
|
+
if (url.origin === PARENT) return route.fulfill({ status: 200, contentType: "text/html", body: parentPage(LOADS[current].visitor) });
|
|
82
|
+
if (url.host === "www.googletagmanager.com" && url.pathname === "/gtag/js") {
|
|
83
|
+
gtagLoaded = true;
|
|
84
|
+
return route.continue();
|
|
85
|
+
}
|
|
86
|
+
if (url.pathname.endsWith("/g/collect")) {
|
|
87
|
+
for (const line of [url.search.slice(1), ...(request.postData() ?? "").split("\n")].filter(Boolean)) {
|
|
88
|
+
const params = new URLSearchParams(url.search);
|
|
89
|
+
for (const [key, value] of new URLSearchParams(line)) params.set(key, value);
|
|
90
|
+
if (params.get("en") === "page_view" && !pageViews.has(current)) {
|
|
91
|
+
pageViews.set(current, Object.fromEntries(["cid", "sid", "sct", "_ss", "_fv"].map((key) => [key, params.get(key)])));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return route.abort();
|
|
96
|
+
});
|
|
97
|
+
const page = await context.newPage();
|
|
98
|
+
for (current = 0; current < LOADS.length; current += 1) {
|
|
99
|
+
await page.goto(`${PARENT}/?load=${current}`);
|
|
100
|
+
for (let waited = 0; !pageViews.has(current) && waited < 15_000; waited += 250) await page.waitForTimeout(250);
|
|
101
|
+
await page.goto("about:blank");
|
|
102
|
+
await page.waitForTimeout(500);
|
|
103
|
+
}
|
|
104
|
+
const cookies = await context.cookies([FRAME, PARENT]);
|
|
105
|
+
|
|
106
|
+
if (!gtagLoaded) {
|
|
107
|
+
console.error("ga4-gs2-canary: cannot run — gtag.js never loaded (network?)");
|
|
108
|
+
process.exitCode = 2;
|
|
109
|
+
} else {
|
|
110
|
+
LOADS.forEach((load, index) => {
|
|
111
|
+
const actual = pageViews.get(index);
|
|
112
|
+
if (!actual) return failures.push(`${load.name}: no page_view reached /g/collect`);
|
|
113
|
+
for (const [key, value] of Object.entries(load.expect)) {
|
|
114
|
+
if (actual[key] !== value) failures.push(`${load.name}: ${key}=${actual[key]} (expected ${value})`);
|
|
115
|
+
}
|
|
116
|
+
console.log(`${load.name}: ${JSON.stringify({ ...actual, cid: actual.cid === CLIENT_ID ? "<seeded>" : actual.cid })}`);
|
|
117
|
+
});
|
|
118
|
+
if (cookies.length > 0) failures.push(`cookies written on the frame or parent origin: ${cookies.map((c) => c.name).join(", ")}`);
|
|
119
|
+
if (failures.length > 0) {
|
|
120
|
+
console.error("ga4-gs2-canary: DRIFT — gtag no longer honours the sandbox seed as expected:");
|
|
121
|
+
for (const failure of failures) console.error(` - ${failure}`);
|
|
122
|
+
console.error("See docs/architecture/analytics-activation.md (GA4) for what GA4 reports in this state.");
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
} else {
|
|
125
|
+
console.log("ga4-gs2-canary: OK — gtag reads the GS2 seed; cid, sid, sct, _ss and _fv match, no cookies written.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
await browser.close();
|
|
130
|
+
}
|