@tokenoftrust/storefront-runner 2.2.75 → 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/lifecycle-boot.ts +1 -1
- package/packages/public-runtime/src/integration/native-amplitude.ts +59 -16
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +108 -1
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +16 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +68 -6
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +5 -6
- 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",
|
|
@@ -140,7 +140,7 @@ export interface BootLifecycleOptions {
|
|
|
140
140
|
*/
|
|
141
141
|
scheduleGa4Sandbox?: NativeGa4SandboxScheduler;
|
|
142
142
|
/**
|
|
143
|
-
* The persistent store behind the Amplitude visitor id and session; defaults to the
|
|
143
|
+
* The persistent store behind the GA4 and Amplitude visitor id and session; defaults to the
|
|
144
144
|
* window's origin storage. Read lazily and only while analytics is eligible.
|
|
145
145
|
*/
|
|
146
146
|
visitorStorage?: () => VisitorContinuityStorage | undefined;
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* Storage is injected by the host and never touched by name here; when it is
|
|
18
18
|
* unavailable the id lives in memory for this document only and the event carries
|
|
19
19
|
* no session (`NO_SESSION_ID`), overcounting visitors rather than failing the event.
|
|
20
|
-
* There is no `user_id`.
|
|
20
|
+
* There is no `user_id`. The same store backs the GA4 adapter's `client_id` and session
|
|
21
|
+
* (`native-ga4.ts`), so both destinations count the same visitors and sessions.
|
|
21
22
|
*
|
|
22
23
|
* Page views also carry channel ATTRIBUTION (UTM tokens + the referrer's origin)
|
|
23
24
|
* and the page LOCATION (`page_hostname` / `page_url`, from the server-stamped
|
|
@@ -73,6 +74,8 @@ export const NO_SESSION_ID = -1 as const;
|
|
|
73
74
|
export const VISITOR_CONTINUITY_STORAGE_KEY = "tot.analytics-continuity.v1" as const;
|
|
74
75
|
export const VISITOR_ID_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000;
|
|
75
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;
|
|
76
79
|
export const AMPLITUDE_HTTP_EVENT_TYPES = Object.freeze([
|
|
77
80
|
"storefront.page.viewed.v1",
|
|
78
81
|
"storefront.page.viewed.v2",
|
|
@@ -171,9 +174,20 @@ export interface VisitorContinuityScope {
|
|
|
171
174
|
siteSurface: string;
|
|
172
175
|
}
|
|
173
176
|
|
|
177
|
+
/**
|
|
178
|
+
* How the current session relates to this document: `first_visit` when this document
|
|
179
|
+
* minted the visitor, `new_session` when it started a new session for a known visitor,
|
|
180
|
+
* otherwise `continuing`. Recorded per continuity instance (one per document), so the
|
|
181
|
+
* first adapter to read the visitor does not hide the arrival from the others.
|
|
182
|
+
*/
|
|
183
|
+
export type VisitorSessionState = "first_visit" | "new_session" | "continuing";
|
|
184
|
+
|
|
174
185
|
export interface VisitorContinuityIds {
|
|
175
186
|
deviceId: string;
|
|
176
187
|
sessionId: number;
|
|
188
|
+
sessionState: VisitorSessionState;
|
|
189
|
+
/** 1-based count of this visitor's sessions, current one included (GA4's session number). */
|
|
190
|
+
sessionCount: number;
|
|
177
191
|
}
|
|
178
192
|
|
|
179
193
|
export interface VisitorContinuity {
|
|
@@ -190,14 +204,17 @@ export interface CreateVisitorContinuityOptions {
|
|
|
190
204
|
}
|
|
191
205
|
|
|
192
206
|
interface VisitorRecord {
|
|
193
|
-
schemaVersion:
|
|
207
|
+
schemaVersion: 2;
|
|
194
208
|
deviceId: string;
|
|
195
209
|
createdAt: number;
|
|
196
210
|
sessionStartedAt: number;
|
|
197
211
|
lastActivityAt: number;
|
|
212
|
+
sessionCount: number;
|
|
198
213
|
}
|
|
199
214
|
|
|
200
|
-
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";
|
|
201
218
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
202
219
|
|
|
203
220
|
export function visitorContinuityStorageKey(scope: VisitorContinuityScope): string {
|
|
@@ -205,22 +222,33 @@ export function visitorContinuityStorageKey(scope: VisitorContinuityScope): stri
|
|
|
205
222
|
return `${VISITOR_CONTINUITY_STORAGE_KEY}:${encodeURIComponent(contextKey)}`;
|
|
206
223
|
}
|
|
207
224
|
|
|
208
|
-
/**
|
|
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
|
+
*/
|
|
209
231
|
function liveVisitorRecord(raw: string | null, now: number): VisitorRecord | null {
|
|
210
232
|
if (!raw) return null;
|
|
211
233
|
let parsed: unknown;
|
|
212
234
|
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
213
|
-
if (!isPlainObject(parsed)
|
|
235
|
+
if (!isPlainObject(parsed)) return null;
|
|
236
|
+
const keys = Object.keys(parsed).sort().join(",");
|
|
214
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;
|
|
215
241
|
if (
|
|
216
|
-
|
|
242
|
+
typeof deviceId !== "string" || !UUID_PATTERN.test(deviceId) ||
|
|
243
|
+
typeof sessionCount !== "number" || !Number.isSafeInteger(sessionCount) ||
|
|
244
|
+
sessionCount < 1 || sessionCount > MAX_VISITOR_SESSION_COUNT ||
|
|
217
245
|
typeof createdAt !== "number" || typeof sessionStartedAt !== "number" ||
|
|
218
246
|
typeof lastActivityAt !== "number" ||
|
|
219
247
|
!Number.isFinite(createdAt) || !Number.isFinite(sessionStartedAt) || !Number.isFinite(lastActivityAt) ||
|
|
220
248
|
createdAt > now || now - createdAt >= VISITOR_ID_LIFETIME_MS ||
|
|
221
249
|
sessionStartedAt < createdAt || lastActivityAt < sessionStartedAt
|
|
222
250
|
) return null;
|
|
223
|
-
return { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt };
|
|
251
|
+
return { schemaVersion: 2, deviceId, createdAt, sessionStartedAt, lastActivityAt, sessionCount };
|
|
224
252
|
}
|
|
225
253
|
|
|
226
254
|
function defaultRandomUUID(): string {
|
|
@@ -235,6 +263,11 @@ function defaultRandomUUID(): string {
|
|
|
235
263
|
export function createVisitorContinuity(options: CreateVisitorContinuityOptions = {}): VisitorContinuity {
|
|
236
264
|
const randomUUID = options.randomUUID ?? defaultRandomUUID;
|
|
237
265
|
const memory = new Map<string, string>();
|
|
266
|
+
const startedHere = new Map<string, { sessionId: number; sessionState: VisitorSessionState }>();
|
|
267
|
+
const stateOf = (key: string, sessionId: number): VisitorSessionState => {
|
|
268
|
+
const started = startedHere.get(key);
|
|
269
|
+
return started && started.sessionId === sessionId ? started.sessionState : "continuing";
|
|
270
|
+
};
|
|
238
271
|
const storage = (): VisitorContinuityStorage | undefined => {
|
|
239
272
|
try { return options.storage?.(); } catch { return undefined; }
|
|
240
273
|
};
|
|
@@ -255,21 +288,30 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
|
|
|
255
288
|
try {
|
|
256
289
|
const existing = liveVisitorRecord(store.getItem(key), now);
|
|
257
290
|
let record: VisitorRecord | null = existing;
|
|
291
|
+
let started: VisitorSessionState | null = null;
|
|
258
292
|
if (!record) {
|
|
259
293
|
const deviceId = mint();
|
|
260
294
|
if (deviceId) {
|
|
261
|
-
record = { schemaVersion:
|
|
295
|
+
record = { schemaVersion: 2, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now, sessionCount: 1 };
|
|
296
|
+
started = "first_visit";
|
|
262
297
|
}
|
|
263
298
|
} else {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
299
|
+
const rolled = now - record.lastActivityAt > SESSION_INACTIVITY_MS;
|
|
300
|
+
const sessionStartedAt = rolled ? now : record.sessionStartedAt;
|
|
301
|
+
if (rolled) started = "new_session";
|
|
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) };
|
|
268
304
|
}
|
|
269
305
|
if (record) {
|
|
270
306
|
store.setItem(key, JSON.stringify(record));
|
|
271
307
|
memory.delete(key);
|
|
272
|
-
|
|
308
|
+
if (started) startedHere.set(key, { sessionId: record.sessionStartedAt, sessionState: started });
|
|
309
|
+
return {
|
|
310
|
+
deviceId: record.deviceId,
|
|
311
|
+
sessionId: record.sessionStartedAt,
|
|
312
|
+
sessionState: stateOf(key, record.sessionStartedAt),
|
|
313
|
+
sessionCount: record.sessionCount,
|
|
314
|
+
};
|
|
273
315
|
}
|
|
274
316
|
} catch {
|
|
275
317
|
// Blocked or full storage: fall through to this document's memory-only visitor.
|
|
@@ -278,15 +320,16 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
|
|
|
278
320
|
let deviceId = memory.get(key);
|
|
279
321
|
if (!deviceId) {
|
|
280
322
|
const minted = mint();
|
|
281
|
-
if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID };
|
|
323
|
+
if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
|
|
282
324
|
deviceId = minted;
|
|
283
325
|
memory.set(key, deviceId);
|
|
284
326
|
}
|
|
285
|
-
return { deviceId, sessionId: NO_SESSION_ID };
|
|
327
|
+
return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
|
|
286
328
|
},
|
|
287
329
|
clear(scope) {
|
|
288
330
|
const key = visitorContinuityStorageKey(scope);
|
|
289
331
|
memory.delete(key);
|
|
332
|
+
startedHere.delete(key);
|
|
290
333
|
try { storage()?.removeItem(key); } catch { /* the early consent boot clears on the next load */ }
|
|
291
334
|
},
|
|
292
335
|
};
|
|
@@ -456,7 +499,7 @@ function createAmplitudeHandler(input: AmplitudeHandlerInput): (event: CommerceE
|
|
|
456
499
|
if (!mapped || input.dedupe.seenOrRemember(mapped.insert_id)) return;
|
|
457
500
|
let identity: VisitorContinuityIds;
|
|
458
501
|
try { identity = input.continuity.readOrMint(scope, mapped.time); }
|
|
459
|
-
catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID }; }
|
|
502
|
+
catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 }; }
|
|
460
503
|
const body = JSON.stringify({
|
|
461
504
|
api_key: input.destinationId,
|
|
462
505
|
events: [{ ...mapped, device_id: identity.deviceId, session_id: identity.sessionId }],
|
|
@@ -10,6 +10,12 @@ import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-val
|
|
|
10
10
|
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,20}$/;
|
|
11
11
|
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
|
+
const CLIENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
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;
|
|
17
|
+
/** gtag's own session-inactivity window, in seconds. */
|
|
18
|
+
const GTAG_SESSION_TIMEOUT_S = 30 * 60;
|
|
13
19
|
const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
|
|
14
20
|
|
|
15
21
|
function plainRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -59,6 +65,95 @@ function validGovernedProperties(value: unknown): value is Record<string, string
|
|
|
59
65
|
!resemblesManagedAnalyticsProtectedValue(item));
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
interface FrameVisitor {
|
|
69
|
+
clientId: string;
|
|
70
|
+
session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing"; count: number };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
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.
|
|
79
|
+
*/
|
|
80
|
+
function parseVisitor(value: unknown): FrameVisitor | null {
|
|
81
|
+
const record = plainRecord(value);
|
|
82
|
+
if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState", "sessionCount"], ["clientId"])) return null;
|
|
83
|
+
const { clientId, sessionId, sessionState, sessionCount } = record;
|
|
84
|
+
if (typeof clientId !== "string" || !CLIENT_ID.test(clientId)) return null;
|
|
85
|
+
if (sessionId === undefined && sessionState === undefined && sessionCount === undefined) return { clientId };
|
|
86
|
+
if (
|
|
87
|
+
typeof sessionId !== "number" || !Number.isSafeInteger(sessionId) || sessionId <= 0 ||
|
|
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)
|
|
93
|
+
) return null;
|
|
94
|
+
return {
|
|
95
|
+
clientId,
|
|
96
|
+
session: {
|
|
97
|
+
idSeconds: Math.floor(sessionId / 1000),
|
|
98
|
+
state: sessionState as "first_visit" | "new_session" | "continuing",
|
|
99
|
+
count: sessionCount,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* gtag keeps its visitor and session in `_ga` / `_ga_<container>` cookies and ignores
|
|
106
|
+
* `client_storage: 'none'` (measured 2026-10-06 against the live gtag.js). This frame's
|
|
107
|
+
* origin is shared by every hosted tenant, so a cookie written here would link one
|
|
108
|
+
* browser across tenants. The frame's `document.cookie` is therefore replaced, before
|
|
109
|
+
* gtag loads, with a jar that lives only as long as this document.
|
|
110
|
+
*/
|
|
111
|
+
function installMemoryCookieJar(doc: Document): Map<string, string> {
|
|
112
|
+
const jar = new Map<string, string>();
|
|
113
|
+
Object.defineProperty(doc, "cookie", {
|
|
114
|
+
configurable: false,
|
|
115
|
+
enumerable: true,
|
|
116
|
+
get: () => Array.from(jar, ([name, value]) => `${name}=${value}`).join("; "),
|
|
117
|
+
set: (raw: unknown) => {
|
|
118
|
+
const [pair = "", ...attributes] = String(raw).split(";");
|
|
119
|
+
const split = pair.indexOf("=");
|
|
120
|
+
if (split <= 0) return;
|
|
121
|
+
const name = pair.slice(0, split).trim();
|
|
122
|
+
const expired = attributes.some((attribute) => {
|
|
123
|
+
const [key = "", value = ""] = attribute.split("=").map((part) => part.trim());
|
|
124
|
+
if (key.toLowerCase() === "max-age") return Number(value) <= 0;
|
|
125
|
+
if (key.toLowerCase() === "expires") return Date.parse(value) <= Date.now();
|
|
126
|
+
return false;
|
|
127
|
+
});
|
|
128
|
+
if (expired) jar.delete(name);
|
|
129
|
+
else jar.set(name, pair.slice(split + 1).trim());
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
return jar;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Seed gtag's session cookie so it reports the parent's session: `continuing` gets a
|
|
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
|
|
142
|
+
* `client_id` and session id and only the new-session / first-visit flags overcount.
|
|
143
|
+
*/
|
|
144
|
+
function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], visitor: FrameVisitor | null): void {
|
|
145
|
+
jar.clear();
|
|
146
|
+
const session = visitor?.session;
|
|
147
|
+
if (!session || session.state === "first_visit") return;
|
|
148
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
149
|
+
const lastSeen = session.state === "continuing" ? nowSeconds : session.idSeconds - GTAG_SESSION_TIMEOUT_S - 1;
|
|
150
|
+
const sessionStart = session.state === "continuing" ? session.idSeconds : lastSeen;
|
|
151
|
+
const sessionNumber = session.state === "continuing" ? session.count : session.count - 1;
|
|
152
|
+
for (const id of ids) {
|
|
153
|
+
jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$o${sessionNumber}$g0$t${lastSeen}$j0$l0$h0`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
62
157
|
function validCampaign(value: unknown): value is Record<string, string> {
|
|
63
158
|
const record = plainRecord(value);
|
|
64
159
|
if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
|
|
@@ -105,6 +200,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
105
200
|
let lastPageLocation = "";
|
|
106
201
|
let tagLoaded = false;
|
|
107
202
|
|
|
203
|
+
const cookieJar = installMemoryCookieJar(doc);
|
|
108
204
|
const dataLayer: unknown[] = [];
|
|
109
205
|
const gtag = function(..._args: unknown[]): void {
|
|
110
206
|
dataLayer.push(arguments);
|
|
@@ -114,13 +210,20 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
114
210
|
|
|
115
211
|
const configure = (message: Record<string, unknown>): void => {
|
|
116
212
|
if (
|
|
117
|
-
!exactKeys(
|
|
213
|
+
!exactKeys(
|
|
214
|
+
message,
|
|
215
|
+
["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties", "visitor"],
|
|
216
|
+
["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"],
|
|
217
|
+
) ||
|
|
118
218
|
message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
|
|
119
219
|
message["analyticsConsent"] !== "granted" ||
|
|
120
220
|
!validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"]) ||
|
|
121
221
|
!validGovernedProperties(message["governedProperties"])
|
|
122
222
|
) return;
|
|
223
|
+
const visitor = message["visitor"] === undefined ? null : parseVisitor(message["visitor"]);
|
|
224
|
+
if (message["visitor"] !== undefined && !visitor) return;
|
|
123
225
|
const ids = message["destinationIds"];
|
|
226
|
+
seedSessionCookies(cookieJar, ids, visitor);
|
|
124
227
|
canonicalOrigin = message["canonicalOrigin"];
|
|
125
228
|
governedProperties = { ...message["governedProperties"] };
|
|
126
229
|
currentDestinations = [...ids];
|
|
@@ -149,6 +252,8 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
149
252
|
allow_ad_personalization_signals: false,
|
|
150
253
|
restricted_data_processing: true,
|
|
151
254
|
ignore_referrer: true,
|
|
255
|
+
...(visitor ? { client_id: visitor.clientId } : {}),
|
|
256
|
+
...(visitor?.session ? { session_id: visitor.session.idSeconds } : {}),
|
|
152
257
|
});
|
|
153
258
|
}
|
|
154
259
|
if (doc.querySelector('script[data-tot-ga4="1"]') === null) {
|
|
@@ -214,6 +319,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
214
319
|
active = false;
|
|
215
320
|
currentDestinations = [];
|
|
216
321
|
lastPageLocation = "";
|
|
322
|
+
cookieJar.clear();
|
|
217
323
|
}
|
|
218
324
|
return;
|
|
219
325
|
}
|
|
@@ -223,6 +329,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
223
329
|
disposed = true;
|
|
224
330
|
currentDestinations = [];
|
|
225
331
|
lastPageLocation = "";
|
|
332
|
+
cookieJar.clear();
|
|
226
333
|
port.close();
|
|
227
334
|
}
|
|
228
335
|
return;
|
|
@@ -17,6 +17,22 @@ export interface NativeGa4SandboxConfig {
|
|
|
17
17
|
* life, so a per-event copy would only widen the hostile boundary.
|
|
18
18
|
*/
|
|
19
19
|
governedProperties: GovernedEventProperties;
|
|
20
|
+
/**
|
|
21
|
+
* The parent's visitor continuity (shared with the Amplitude adapter). gtag keeps no
|
|
22
|
+
* state on the frame origin, which every hosted tenant shares, so the visitor and
|
|
23
|
+
* session arrive here instead. Absent when the browser cannot mint an id.
|
|
24
|
+
*/
|
|
25
|
+
visitor?: NativeGa4SandboxVisitor;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NativeGa4SandboxVisitor {
|
|
29
|
+
/** Lowercase random UUID, the GA4 `client_id`. */
|
|
30
|
+
clientId: string;
|
|
31
|
+
/** Session start in epoch ms; absent (with the other session fields) when origin storage is unavailable. */
|
|
32
|
+
sessionId?: number;
|
|
33
|
+
sessionState?: "first_visit" | "new_session" | "continuing";
|
|
34
|
+
/** 1-based session number, GA4's `sct`. */
|
|
35
|
+
sessionCount?: number;
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
export interface NativeGa4PageViewCommand {
|
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* lifecycle subscription. It never loads Google code into the regulated parent.
|
|
6
6
|
* Instead, a closed command crosses one private MessagePort into the reviewed,
|
|
7
7
|
* isolated-origin GA4 sandbox after consent and logical-event dedupe have passed.
|
|
8
|
+
*
|
|
9
|
+
* VISITOR CONTINUITY. The GA4 `client_id` and session come from the same
|
|
10
|
+
* `VisitorContinuity` store as the Amplitude adapter's `device_id`/`session_id`
|
|
11
|
+
* (`native-amplitude.ts`), so both destinations count one visitor per browser, tenant,
|
|
12
|
+
* environment and site surface. They are read or minted only on the eligible bind path
|
|
13
|
+
* and cross into the frame in the `configure` message; the frame keeps gtag's cookies in
|
|
14
|
+
* memory, never on its origin (see `native-ga4-sandbox-entry.ts`). An update into
|
|
15
|
+
* ineligibility clears the visitor, so a later grant configures a fresh one.
|
|
8
16
|
*/
|
|
9
17
|
import type { IntegrationLifecycleContext, IntegrationLifecycleModule, LifecycleUpdateReason } from "./lifecycle.js";
|
|
10
18
|
import type { CommerceEventType } from "./events.js";
|
|
@@ -17,6 +25,12 @@ import {
|
|
|
17
25
|
} from "./platform-adapters.js";
|
|
18
26
|
import { INTEGRATION_ENVIRONMENTS, type IntegrationEnvironment } from "./environment.js";
|
|
19
27
|
import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
|
|
28
|
+
import {
|
|
29
|
+
createVisitorContinuity,
|
|
30
|
+
NO_SESSION_ID,
|
|
31
|
+
type VisitorContinuity,
|
|
32
|
+
type VisitorContinuityScope,
|
|
33
|
+
} from "./native-amplitude.js";
|
|
20
34
|
import {
|
|
21
35
|
buildGovernedEventProperties,
|
|
22
36
|
type GovernedEventProperties,
|
|
@@ -24,6 +38,7 @@ import {
|
|
|
24
38
|
import type {
|
|
25
39
|
NativeGa4SandboxCommand,
|
|
26
40
|
NativeGa4SandboxConfig,
|
|
41
|
+
NativeGa4SandboxVisitor,
|
|
27
42
|
} from "./native-ga4-sandbox.js";
|
|
28
43
|
import {
|
|
29
44
|
NATIVE_GA4_SANDBOX_ASSET_URL,
|
|
@@ -69,9 +84,14 @@ export type NativeGa4Observer = (observation: NativeGa4Observation) => void;
|
|
|
69
84
|
export interface NativeGa4ModuleOptions {
|
|
70
85
|
transport: NativeGa4Transport | NativeGa4TransportSend;
|
|
71
86
|
observe?: NativeGa4Observer;
|
|
87
|
+
/** The host's visitor store, shared with Amplitude; defaults to memory-only (one visitor per document, no session). */
|
|
88
|
+
continuity?: VisitorContinuity;
|
|
89
|
+
/** Epoch-millisecond clock for session activity; defaults to the wall clock. */
|
|
90
|
+
now?: () => number;
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
interface ActiveGa4Config {
|
|
94
|
+
scope: VisitorContinuityScope;
|
|
75
95
|
destinationIds: readonly string[];
|
|
76
96
|
environment: IntegrationEnvironment;
|
|
77
97
|
siteSurface: IntegrationLifecycleContext["environment"]["siteSurface"];
|
|
@@ -109,8 +129,10 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
|
|
|
109
129
|
// Unlabellable traffic is not collected: without a provable tenant and stage
|
|
110
130
|
// every number downstream is unattributable, so this joins the other gates.
|
|
111
131
|
const governedProperties = buildGovernedEventProperties(ctx.environment, ctx.tenantPublicId);
|
|
112
|
-
|
|
132
|
+
const scope = continuityScope(ctx);
|
|
133
|
+
if (!governedProperties || !scope) return null;
|
|
113
134
|
return {
|
|
135
|
+
scope,
|
|
114
136
|
destinationIds,
|
|
115
137
|
environment: ctx.environment.environment,
|
|
116
138
|
siteSurface: ctx.environment.siteSurface,
|
|
@@ -119,6 +141,34 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
|
|
|
119
141
|
};
|
|
120
142
|
}
|
|
121
143
|
|
|
144
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
145
|
+
|
|
146
|
+
/** The consent broker's context triple; null when the context cannot name one. */
|
|
147
|
+
function continuityScope(ctx: IntegrationLifecycleContext): VisitorContinuityScope | null {
|
|
148
|
+
const { tenantPublicId } = ctx;
|
|
149
|
+
const { environment, siteSurface } = ctx.environment;
|
|
150
|
+
if (
|
|
151
|
+
typeof tenantPublicId !== "string" || tenantPublicId.length === 0 ||
|
|
152
|
+
typeof environment !== "string" || typeof siteSurface !== "string"
|
|
153
|
+
) return null;
|
|
154
|
+
return { tenantPublicId, environment, siteSurface };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A visitor the frame accepts; a browser that cannot mint an id sends none and gtag keeps a per-document one. */
|
|
158
|
+
function frameVisitor(continuity: VisitorContinuity, scope: VisitorContinuityScope, now: number): NativeGa4SandboxVisitor | undefined {
|
|
159
|
+
const ids = continuity.readOrMint(scope, now);
|
|
160
|
+
if (!UUID_PATTERN.test(ids.deviceId)) return undefined;
|
|
161
|
+
if (ids.sessionId === NO_SESSION_ID || !Number.isSafeInteger(ids.sessionId) || ids.sessionId <= 0) {
|
|
162
|
+
return { clientId: ids.deviceId };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
clientId: ids.deviceId,
|
|
166
|
+
sessionId: ids.sessionId,
|
|
167
|
+
sessionState: ids.sessionState,
|
|
168
|
+
sessionCount: ids.sessionCount,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
122
172
|
function hasAnalyticsConsent(ctx: IntegrationLifecycleContext): boolean {
|
|
123
173
|
const consent = parseConsentSnapshot(ctx.consent);
|
|
124
174
|
return consent !== null && isPurposeEligible(consent, "analytics");
|
|
@@ -194,6 +244,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
194
244
|
let currentPageLocation: string | null = null;
|
|
195
245
|
const delivered = new Set<string>();
|
|
196
246
|
const deliveredOrder: string[] = [];
|
|
247
|
+
const continuity = options.continuity ?? createVisitorContinuity();
|
|
248
|
+
const now = options.now ?? (() => Date.now());
|
|
197
249
|
|
|
198
250
|
const transport = typeof options.transport === "function" ? undefined : options.transport;
|
|
199
251
|
const send = typeof options.transport === "function" ? options.transport : options.transport.send.bind(options.transport);
|
|
@@ -221,11 +273,17 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
221
273
|
}
|
|
222
274
|
};
|
|
223
275
|
|
|
224
|
-
|
|
276
|
+
/** `clearIfIneligible`: an update that loses analytics eligibility forgets the visitor. */
|
|
277
|
+
const apply = (ctx: IntegrationLifecycleContext, clearIfIneligible: boolean): void => {
|
|
225
278
|
if (!stop() || disposed) return;
|
|
226
279
|
const next = configuredFor(ctx);
|
|
227
280
|
if (!next) {
|
|
228
|
-
|
|
281
|
+
const consented = hasAnalyticsConsent(ctx);
|
|
282
|
+
if (!consented && clearIfIneligible) {
|
|
283
|
+
const scope = continuityScope(ctx);
|
|
284
|
+
try { if (scope) continuity.clear(scope); } catch { /* the early consent boot clears on the next load */ }
|
|
285
|
+
}
|
|
286
|
+
observeSafely(options.observe, consented ? "configuration_invalid" : "consent_ineligible");
|
|
229
287
|
return;
|
|
230
288
|
}
|
|
231
289
|
if (!ctx.subscribe) {
|
|
@@ -233,12 +291,14 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
233
291
|
return;
|
|
234
292
|
}
|
|
235
293
|
try {
|
|
236
|
-
const
|
|
294
|
+
const visitor = frameVisitor(continuity, next.scope, now());
|
|
295
|
+
const configuration: NativeGa4SandboxConfig = {
|
|
237
296
|
schemaVersion: 1 as const,
|
|
238
297
|
destinationIds: [...next.destinationIds],
|
|
239
298
|
canonicalOrigin: next.canonicalOrigin,
|
|
240
299
|
analyticsConsent: "granted" as const,
|
|
241
300
|
governedProperties: { ...next.governedProperties },
|
|
301
|
+
...(visitor ? { visitor } : {}),
|
|
242
302
|
};
|
|
243
303
|
runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
|
|
244
304
|
} catch {
|
|
@@ -264,6 +324,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
264
324
|
return;
|
|
265
325
|
}
|
|
266
326
|
if (command.type === "page_view") currentPageLocation = `${current.canonicalOrigin}${command.path}`;
|
|
327
|
+
// Record activity so the shared session rolls on inactivity, not on page-load age.
|
|
328
|
+
try { continuity.readOrMint(current.scope, now()); } catch { /* activity is best-effort */ }
|
|
267
329
|
delivered.add(event.id);
|
|
268
330
|
deliveredOrder.push(event.id);
|
|
269
331
|
if (deliveredOrder.length > 1_024) {
|
|
@@ -284,8 +346,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
284
346
|
};
|
|
285
347
|
|
|
286
348
|
return {
|
|
287
|
-
init
|
|
288
|
-
update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx); },
|
|
349
|
+
init(ctx) { return apply(ctx, false); },
|
|
350
|
+
update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx, true); },
|
|
289
351
|
dispose() {
|
|
290
352
|
disposed = true;
|
|
291
353
|
stop();
|
|
@@ -41,7 +41,7 @@ export interface PlatformAdapterEntryMarker {
|
|
|
41
41
|
export interface PlatformAdapterRuntimeHost {
|
|
42
42
|
fetch?: typeof fetch;
|
|
43
43
|
nativeGa4Transport?: NativeGa4Transport;
|
|
44
|
-
/** The host's persistent visitor store
|
|
44
|
+
/** The host's persistent visitor store, shared by GA4 and Amplitude; absent keeps visitors memory-only. */
|
|
45
45
|
visitorContinuity?: VisitorContinuity;
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -74,9 +74,9 @@ const AMPLITUDE_MARKER: PlatformAdapterEntryMarker = Object.freeze({
|
|
|
74
74
|
export const PLATFORM_ADAPTER_RUNTIME_REGISTRY: readonly PlatformAdapterRuntimeRegistration[] = Object.freeze([
|
|
75
75
|
Object.freeze({
|
|
76
76
|
marker: GA4_MARKER,
|
|
77
|
-
create: ({ nativeGa4Transport }: PlatformAdapterRuntimeHost) => {
|
|
77
|
+
create: ({ nativeGa4Transport, visitorContinuity }: PlatformAdapterRuntimeHost) => {
|
|
78
78
|
if (!nativeGa4Transport) throw new Error("platform_adapter_ga4_sandbox_unavailable");
|
|
79
|
-
return createNativeGa4Module({ transport: nativeGa4Transport });
|
|
79
|
+
return createNativeGa4Module({ transport: nativeGa4Transport, continuity: visitorContinuity });
|
|
80
80
|
},
|
|
81
81
|
}),
|
|
82
82
|
Object.freeze({
|
|
@@ -154,9 +154,8 @@ export function makePlatformAdapterRuntimeLoader(
|
|
|
154
154
|
nativeGa4Transport: registration.marker.appId === GOOGLE_MEASUREMENT_ADAPTER.adapterId
|
|
155
155
|
? options.nativeGa4Transport?.()
|
|
156
156
|
: undefined,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
: undefined,
|
|
157
|
+
// One store for both destinations, so they agree on visitors and sessions.
|
|
158
|
+
visitorContinuity: options.visitorContinuity,
|
|
160
159
|
});
|
|
161
160
|
};
|
|
162
161
|
}
|
|
@@ -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
|
+
}
|