@tokenoftrust/storefront-runner 2.2.75 → 2.2.76
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 +31 -8
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +93 -1
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +14 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +63 -6
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +5 -6
|
@@ -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-n9b6Y8jO/N+uwfItpBlKcnLoRaKnidYcvDkuixl+DwY='",
|
|
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.76",
|
|
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
|
|
@@ -171,9 +172,18 @@ export interface VisitorContinuityScope {
|
|
|
171
172
|
siteSurface: string;
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
/**
|
|
176
|
+
* How the current session relates to this document: `first_visit` when this document
|
|
177
|
+
* minted the visitor, `new_session` when it started a new session for a known visitor,
|
|
178
|
+
* otherwise `continuing`. Recorded per continuity instance (one per document), so the
|
|
179
|
+
* first adapter to read the visitor does not hide the arrival from the others.
|
|
180
|
+
*/
|
|
181
|
+
export type VisitorSessionState = "first_visit" | "new_session" | "continuing";
|
|
182
|
+
|
|
174
183
|
export interface VisitorContinuityIds {
|
|
175
184
|
deviceId: string;
|
|
176
185
|
sessionId: number;
|
|
186
|
+
sessionState: VisitorSessionState;
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
export interface VisitorContinuity {
|
|
@@ -235,6 +245,11 @@ function defaultRandomUUID(): string {
|
|
|
235
245
|
export function createVisitorContinuity(options: CreateVisitorContinuityOptions = {}): VisitorContinuity {
|
|
236
246
|
const randomUUID = options.randomUUID ?? defaultRandomUUID;
|
|
237
247
|
const memory = new Map<string, string>();
|
|
248
|
+
const startedHere = new Map<string, { sessionId: number; sessionState: VisitorSessionState }>();
|
|
249
|
+
const stateOf = (key: string, sessionId: number): VisitorSessionState => {
|
|
250
|
+
const started = startedHere.get(key);
|
|
251
|
+
return started && started.sessionId === sessionId ? started.sessionState : "continuing";
|
|
252
|
+
};
|
|
238
253
|
const storage = (): VisitorContinuityStorage | undefined => {
|
|
239
254
|
try { return options.storage?.(); } catch { return undefined; }
|
|
240
255
|
};
|
|
@@ -255,21 +270,28 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
|
|
|
255
270
|
try {
|
|
256
271
|
const existing = liveVisitorRecord(store.getItem(key), now);
|
|
257
272
|
let record: VisitorRecord | null = existing;
|
|
273
|
+
let started: VisitorSessionState | null = null;
|
|
258
274
|
if (!record) {
|
|
259
275
|
const deviceId = mint();
|
|
260
276
|
if (deviceId) {
|
|
261
277
|
record = { schemaVersion: 1, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now };
|
|
278
|
+
started = "first_visit";
|
|
262
279
|
}
|
|
263
280
|
} else {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
281
|
+
const rolled = now - record.lastActivityAt > SESSION_INACTIVITY_MS;
|
|
282
|
+
const sessionStartedAt = rolled ? now : record.sessionStartedAt;
|
|
283
|
+
if (rolled) started = "new_session";
|
|
267
284
|
record = { ...record, sessionStartedAt, lastActivityAt: Math.max(now, record.lastActivityAt) };
|
|
268
285
|
}
|
|
269
286
|
if (record) {
|
|
270
287
|
store.setItem(key, JSON.stringify(record));
|
|
271
288
|
memory.delete(key);
|
|
272
|
-
|
|
289
|
+
if (started) startedHere.set(key, { sessionId: record.sessionStartedAt, sessionState: started });
|
|
290
|
+
return {
|
|
291
|
+
deviceId: record.deviceId,
|
|
292
|
+
sessionId: record.sessionStartedAt,
|
|
293
|
+
sessionState: stateOf(key, record.sessionStartedAt),
|
|
294
|
+
};
|
|
273
295
|
}
|
|
274
296
|
} catch {
|
|
275
297
|
// Blocked or full storage: fall through to this document's memory-only visitor.
|
|
@@ -278,15 +300,16 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
|
|
|
278
300
|
let deviceId = memory.get(key);
|
|
279
301
|
if (!deviceId) {
|
|
280
302
|
const minted = mint();
|
|
281
|
-
if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID };
|
|
303
|
+
if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
|
|
282
304
|
deviceId = minted;
|
|
283
305
|
memory.set(key, deviceId);
|
|
284
306
|
}
|
|
285
|
-
return { deviceId, sessionId: NO_SESSION_ID };
|
|
307
|
+
return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
|
|
286
308
|
},
|
|
287
309
|
clear(scope) {
|
|
288
310
|
const key = visitorContinuityStorageKey(scope);
|
|
289
311
|
memory.delete(key);
|
|
312
|
+
startedHere.delete(key);
|
|
290
313
|
try { storage()?.removeItem(key); } catch { /* the early consent boot clears on the next load */ }
|
|
291
314
|
},
|
|
292
315
|
};
|
|
@@ -456,7 +479,7 @@ function createAmplitudeHandler(input: AmplitudeHandlerInput): (event: CommerceE
|
|
|
456
479
|
if (!mapped || input.dedupe.seenOrRemember(mapped.insert_id)) return;
|
|
457
480
|
let identity: VisitorContinuityIds;
|
|
458
481
|
try { identity = input.continuity.readOrMint(scope, mapped.time); }
|
|
459
|
-
catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID }; }
|
|
482
|
+
catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" }; }
|
|
460
483
|
const body = JSON.stringify({
|
|
461
484
|
api_key: input.destinationId,
|
|
462
485
|
events: [{ ...mapped, device_id: identity.deviceId, session_id: identity.sessionId }],
|
|
@@ -10,6 +10,10 @@ 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
|
+
/** gtag's own session-inactivity window, in seconds. */
|
|
16
|
+
const GTAG_SESSION_TIMEOUT_S = 30 * 60;
|
|
13
17
|
const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
|
|
14
18
|
|
|
15
19
|
function plainRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -59,6 +63,82 @@ function validGovernedProperties(value: unknown): value is Record<string, string
|
|
|
59
63
|
!resemblesManagedAnalyticsProtectedValue(item));
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
interface FrameVisitor {
|
|
67
|
+
clientId: string;
|
|
68
|
+
session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The visitor crosses the same hostile boundary: a lowercase UUID and, together or not
|
|
73
|
+
* at all, a positive integer epoch-ms session start plus its closed state. Anything else
|
|
74
|
+
* refuses the whole configure rather than falling back to an id gtag would invent.
|
|
75
|
+
*/
|
|
76
|
+
function parseVisitor(value: unknown): FrameVisitor | null {
|
|
77
|
+
const record = plainRecord(value);
|
|
78
|
+
if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState"], ["clientId"])) return null;
|
|
79
|
+
const { clientId, sessionId, sessionState } = record;
|
|
80
|
+
if (typeof clientId !== "string" || !CLIENT_ID.test(clientId)) return null;
|
|
81
|
+
if (sessionId === undefined && sessionState === undefined) return { clientId };
|
|
82
|
+
if (
|
|
83
|
+
typeof sessionId !== "number" || !Number.isSafeInteger(sessionId) || sessionId <= 0 ||
|
|
84
|
+
!SESSION_STATES.has(sessionState)
|
|
85
|
+
) return null;
|
|
86
|
+
return {
|
|
87
|
+
clientId,
|
|
88
|
+
session: { idSeconds: Math.floor(sessionId / 1000), state: sessionState as "first_visit" | "new_session" | "continuing" },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* gtag keeps its visitor and session in `_ga` / `_ga_<container>` cookies and ignores
|
|
94
|
+
* `client_storage: 'none'` (measured 2026-10-06 against the live gtag.js). This frame's
|
|
95
|
+
* origin is shared by every hosted tenant, so a cookie written here would link one
|
|
96
|
+
* browser across tenants. The frame's `document.cookie` is therefore replaced, before
|
|
97
|
+
* gtag loads, with a jar that lives only as long as this document.
|
|
98
|
+
*/
|
|
99
|
+
function installMemoryCookieJar(doc: Document): Map<string, string> {
|
|
100
|
+
const jar = new Map<string, string>();
|
|
101
|
+
Object.defineProperty(doc, "cookie", {
|
|
102
|
+
configurable: false,
|
|
103
|
+
enumerable: true,
|
|
104
|
+
get: () => Array.from(jar, ([name, value]) => `${name}=${value}`).join("; "),
|
|
105
|
+
set: (raw: unknown) => {
|
|
106
|
+
const [pair = "", ...attributes] = String(raw).split(";");
|
|
107
|
+
const split = pair.indexOf("=");
|
|
108
|
+
if (split <= 0) return;
|
|
109
|
+
const name = pair.slice(0, split).trim();
|
|
110
|
+
const expired = attributes.some((attribute) => {
|
|
111
|
+
const [key = "", value = ""] = attribute.split("=").map((part) => part.trim());
|
|
112
|
+
if (key.toLowerCase() === "max-age") return Number(value) <= 0;
|
|
113
|
+
if (key.toLowerCase() === "expires") return Date.parse(value) <= Date.now();
|
|
114
|
+
return false;
|
|
115
|
+
});
|
|
116
|
+
if (expired) jar.delete(name);
|
|
117
|
+
else jar.set(name, pair.slice(split + 1).trim());
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
return jar;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Seed gtag's session cookie so it reports the parent's session: `continuing` gets a
|
|
125
|
+
* live session (no `session_start`, no `first_visit`); `new_session` gets an expired one,
|
|
126
|
+
* so gtag starts a session for a returning visitor; `first_visit` gets none. The field
|
|
127
|
+
* layout is gtag's own; if gtag stops parsing it, collection still carries the parent's
|
|
128
|
+
* `client_id` and session id and only the new-session / first-visit flags overcount.
|
|
129
|
+
*/
|
|
130
|
+
function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], visitor: FrameVisitor | null): void {
|
|
131
|
+
jar.clear();
|
|
132
|
+
const session = visitor?.session;
|
|
133
|
+
if (!session || session.state === "first_visit") return;
|
|
134
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
135
|
+
const lastSeen = session.state === "continuing" ? nowSeconds : session.idSeconds - GTAG_SESSION_TIMEOUT_S - 1;
|
|
136
|
+
const sessionStart = session.state === "continuing" ? session.idSeconds : lastSeen;
|
|
137
|
+
for (const id of ids) {
|
|
138
|
+
jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$o1$g0$t${lastSeen}$j0$l0$h0`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
62
142
|
function validCampaign(value: unknown): value is Record<string, string> {
|
|
63
143
|
const record = plainRecord(value);
|
|
64
144
|
if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
|
|
@@ -105,6 +185,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
105
185
|
let lastPageLocation = "";
|
|
106
186
|
let tagLoaded = false;
|
|
107
187
|
|
|
188
|
+
const cookieJar = installMemoryCookieJar(doc);
|
|
108
189
|
const dataLayer: unknown[] = [];
|
|
109
190
|
const gtag = function(..._args: unknown[]): void {
|
|
110
191
|
dataLayer.push(arguments);
|
|
@@ -114,13 +195,20 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
114
195
|
|
|
115
196
|
const configure = (message: Record<string, unknown>): void => {
|
|
116
197
|
if (
|
|
117
|
-
!exactKeys(
|
|
198
|
+
!exactKeys(
|
|
199
|
+
message,
|
|
200
|
+
["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties", "visitor"],
|
|
201
|
+
["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"],
|
|
202
|
+
) ||
|
|
118
203
|
message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
|
|
119
204
|
message["analyticsConsent"] !== "granted" ||
|
|
120
205
|
!validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"]) ||
|
|
121
206
|
!validGovernedProperties(message["governedProperties"])
|
|
122
207
|
) return;
|
|
208
|
+
const visitor = message["visitor"] === undefined ? null : parseVisitor(message["visitor"]);
|
|
209
|
+
if (message["visitor"] !== undefined && !visitor) return;
|
|
123
210
|
const ids = message["destinationIds"];
|
|
211
|
+
seedSessionCookies(cookieJar, ids, visitor);
|
|
124
212
|
canonicalOrigin = message["canonicalOrigin"];
|
|
125
213
|
governedProperties = { ...message["governedProperties"] };
|
|
126
214
|
currentDestinations = [...ids];
|
|
@@ -149,6 +237,8 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
149
237
|
allow_ad_personalization_signals: false,
|
|
150
238
|
restricted_data_processing: true,
|
|
151
239
|
ignore_referrer: true,
|
|
240
|
+
...(visitor ? { client_id: visitor.clientId } : {}),
|
|
241
|
+
...(visitor?.session ? { session_id: visitor.session.idSeconds } : {}),
|
|
152
242
|
});
|
|
153
243
|
}
|
|
154
244
|
if (doc.querySelector('script[data-tot-ga4="1"]') === null) {
|
|
@@ -214,6 +304,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
214
304
|
active = false;
|
|
215
305
|
currentDestinations = [];
|
|
216
306
|
lastPageLocation = "";
|
|
307
|
+
cookieJar.clear();
|
|
217
308
|
}
|
|
218
309
|
return;
|
|
219
310
|
}
|
|
@@ -223,6 +314,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
|
|
|
223
314
|
disposed = true;
|
|
224
315
|
currentDestinations = [];
|
|
225
316
|
lastPageLocation = "";
|
|
317
|
+
cookieJar.clear();
|
|
226
318
|
port.close();
|
|
227
319
|
}
|
|
228
320
|
return;
|
|
@@ -17,6 +17,20 @@ 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 `sessionState`) when origin storage is unavailable. */
|
|
32
|
+
sessionId?: number;
|
|
33
|
+
sessionState?: "first_visit" | "new_session" | "continuing";
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
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,29 @@ 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 { clientId: ids.deviceId, sessionId: ids.sessionId, sessionState: ids.sessionState };
|
|
165
|
+
}
|
|
166
|
+
|
|
122
167
|
function hasAnalyticsConsent(ctx: IntegrationLifecycleContext): boolean {
|
|
123
168
|
const consent = parseConsentSnapshot(ctx.consent);
|
|
124
169
|
return consent !== null && isPurposeEligible(consent, "analytics");
|
|
@@ -194,6 +239,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
194
239
|
let currentPageLocation: string | null = null;
|
|
195
240
|
const delivered = new Set<string>();
|
|
196
241
|
const deliveredOrder: string[] = [];
|
|
242
|
+
const continuity = options.continuity ?? createVisitorContinuity();
|
|
243
|
+
const now = options.now ?? (() => Date.now());
|
|
197
244
|
|
|
198
245
|
const transport = typeof options.transport === "function" ? undefined : options.transport;
|
|
199
246
|
const send = typeof options.transport === "function" ? options.transport : options.transport.send.bind(options.transport);
|
|
@@ -221,11 +268,17 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
221
268
|
}
|
|
222
269
|
};
|
|
223
270
|
|
|
224
|
-
|
|
271
|
+
/** `clearIfIneligible`: an update that loses analytics eligibility forgets the visitor. */
|
|
272
|
+
const apply = (ctx: IntegrationLifecycleContext, clearIfIneligible: boolean): void => {
|
|
225
273
|
if (!stop() || disposed) return;
|
|
226
274
|
const next = configuredFor(ctx);
|
|
227
275
|
if (!next) {
|
|
228
|
-
|
|
276
|
+
const consented = hasAnalyticsConsent(ctx);
|
|
277
|
+
if (!consented && clearIfIneligible) {
|
|
278
|
+
const scope = continuityScope(ctx);
|
|
279
|
+
try { if (scope) continuity.clear(scope); } catch { /* the early consent boot clears on the next load */ }
|
|
280
|
+
}
|
|
281
|
+
observeSafely(options.observe, consented ? "configuration_invalid" : "consent_ineligible");
|
|
229
282
|
return;
|
|
230
283
|
}
|
|
231
284
|
if (!ctx.subscribe) {
|
|
@@ -233,12 +286,14 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
233
286
|
return;
|
|
234
287
|
}
|
|
235
288
|
try {
|
|
236
|
-
const
|
|
289
|
+
const visitor = frameVisitor(continuity, next.scope, now());
|
|
290
|
+
const configuration: NativeGa4SandboxConfig = {
|
|
237
291
|
schemaVersion: 1 as const,
|
|
238
292
|
destinationIds: [...next.destinationIds],
|
|
239
293
|
canonicalOrigin: next.canonicalOrigin,
|
|
240
294
|
analyticsConsent: "granted" as const,
|
|
241
295
|
governedProperties: { ...next.governedProperties },
|
|
296
|
+
...(visitor ? { visitor } : {}),
|
|
242
297
|
};
|
|
243
298
|
runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
|
|
244
299
|
} catch {
|
|
@@ -264,6 +319,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
264
319
|
return;
|
|
265
320
|
}
|
|
266
321
|
if (command.type === "page_view") currentPageLocation = `${current.canonicalOrigin}${command.path}`;
|
|
322
|
+
// Record activity so the shared session rolls on inactivity, not on page-load age.
|
|
323
|
+
try { continuity.readOrMint(current.scope, now()); } catch { /* activity is best-effort */ }
|
|
267
324
|
delivered.add(event.id);
|
|
268
325
|
deliveredOrder.push(event.id);
|
|
269
326
|
if (deliveredOrder.length > 1_024) {
|
|
@@ -284,8 +341,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
284
341
|
};
|
|
285
342
|
|
|
286
343
|
return {
|
|
287
|
-
init
|
|
288
|
-
update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx); },
|
|
344
|
+
init(ctx) { return apply(ctx, false); },
|
|
345
|
+
update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx, true); },
|
|
289
346
|
dispose() {
|
|
290
347
|
disposed = true;
|
|
291
348
|
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
|
}
|