@maple-dev/browser 0.3.0 → 0.4.0
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/LICENSE +21 -0
- package/README.md +36 -1
- package/dist/index.d.mts +92 -8
- package/dist/index.mjs +437 -727
- package/dist/replay-session-B_ACRogX.mjs +390 -0
- package/dist/sink-DHTfvFgl.mjs +1149 -0
- package/package.json +3 -3
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
//#region ../browser-session/src/visitor.ts
|
|
2
|
+
/**
|
|
3
|
+
* A persistent per-browser visitor id.
|
|
4
|
+
*
|
|
5
|
+
* Sessions rotate after 30 minutes idle (see `session.ts`), which makes them
|
|
6
|
+
* useless for "how many people visited" — every long gap mints a new one. The
|
|
7
|
+
* visitor id is the stable identifier that `uniq(VisitorId)` counts, and the
|
|
8
|
+
* only place that knows whether a visitor is new (a self-join against earlier
|
|
9
|
+
* sessions is both a second full scan and wrong past the warehouse's 30-day
|
|
10
|
+
* TTL, which drops the history the join would need).
|
|
11
|
+
*
|
|
12
|
+
* This is a first-party persistent identifier: it requires a cookie/consent
|
|
13
|
+
* notice under ePrivacy, and it is the one thing here that `persistVisitorId:
|
|
14
|
+
* false` and Global Privacy Control turn off.
|
|
15
|
+
*
|
|
16
|
+
* ## Why two stores
|
|
17
|
+
*
|
|
18
|
+
* The id lives in **both** localStorage and a cookie, the same hybrid posthog-js
|
|
19
|
+
* uses for its `localStorage+cookie` persistence:
|
|
20
|
+
*
|
|
21
|
+
* - localStorage is the durable copy. Safari's ITP caps the lifetime of any
|
|
22
|
+
* cookie set from `document.cookie` at 7 days, so a cookie alone would silently
|
|
23
|
+
* turn returning visitors into new ones every week.
|
|
24
|
+
* - The cookie is the *cross-subdomain carrier*. localStorage is origin-scoped,
|
|
25
|
+
* so a marketing site on `example.com` and an app on `app.example.com` can
|
|
26
|
+
* never see each other's id; a cookie scoped to the registered domain can.
|
|
27
|
+
* That is what links an anonymous pre-signup visit to the account it becomes.
|
|
28
|
+
*
|
|
29
|
+
* Reads prefer the cookie and mirror the winner back, so an existing
|
|
30
|
+
* localStorage-only visitor converges onto the shared id instead of being reset.
|
|
31
|
+
*
|
|
32
|
+
* ## What is deliberately *not* shared
|
|
33
|
+
*
|
|
34
|
+
* The **session** id stays origin-scoped (sessionStorage, `session.ts`). Unlike
|
|
35
|
+
* PostHog, our `session_replays` table is a ReplacingMergeTree keyed
|
|
36
|
+
* `(OrgId, SessionId)` whose fields resolve by `argMax(field, Version)` over the
|
|
37
|
+
* whole row — two origins posting different ServiceName/EntryPath under one
|
|
38
|
+
* session id would overwrite each other rather than merge. Sessions stay
|
|
39
|
+
* per-surface; `VisitorId` is the join key between them.
|
|
40
|
+
*/
|
|
41
|
+
const STORAGE_KEY$1 = "maple.visitor";
|
|
42
|
+
/**
|
|
43
|
+
* Cookie names cannot contain `.` per RFC 6265's token grammar, so this is not
|
|
44
|
+
* simply `maple.visitor`.
|
|
45
|
+
*/
|
|
46
|
+
const COOKIE_NAME = "maple_visitor";
|
|
47
|
+
/**
|
|
48
|
+
* Match the ~13-month ceiling browsers and privacy regimes settled on for
|
|
49
|
+
* first-party identifiers. Cheap to enforce from the start; retrofitting an
|
|
50
|
+
* expiry onto ids already in the wild is not.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_AGE_MS = 3456e7;
|
|
53
|
+
let enabled = true;
|
|
54
|
+
/** Memoized so the hot path never re-reads storage. */
|
|
55
|
+
let cached;
|
|
56
|
+
let persisted = false;
|
|
57
|
+
let mintedThisLoad = false;
|
|
58
|
+
/** Scope the cookie to the registered domain so subdomains share the id. */
|
|
59
|
+
let crossSubdomainCookie = true;
|
|
60
|
+
/** Explicit `Domain=` override; `""` forces a host-only cookie. */
|
|
61
|
+
let cookieDomainOverride;
|
|
62
|
+
/** Memoized probe result. `undefined` = not resolved yet. */
|
|
63
|
+
let probedCookieDomain;
|
|
64
|
+
/**
|
|
65
|
+
* Apply the host app's cookie configuration. Called from `configurePrivacy`, so
|
|
66
|
+
* both SDKs get it from the single call they already make.
|
|
67
|
+
*
|
|
68
|
+
* Like the consent gates, this only ever *tightens*: an app that initializes two
|
|
69
|
+
* SDKs, only one of which passes a `privacy` block, must not have the other's
|
|
70
|
+
* absent option widen the cookie back out to every subdomain.
|
|
71
|
+
*
|
|
72
|
+
* "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
|
|
73
|
+
* first-write-wins: `""` (host-only) is the tightest value there is, and a
|
|
74
|
+
* second SDK asking for it has to win over an earlier `"example.com"`. Between
|
|
75
|
+
* two non-empty domains the shorter one is the broader — `example.com` covers
|
|
76
|
+
* `app.example.com` and not the reverse — so the longer string wins.
|
|
77
|
+
*/
|
|
78
|
+
function configureVisitorCookie(options) {
|
|
79
|
+
if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
|
|
80
|
+
if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
|
|
81
|
+
probedCookieDomain = void 0;
|
|
82
|
+
}
|
|
83
|
+
/** The narrower of two `Domain=` values, treating `undefined` as "unset". */
|
|
84
|
+
function tighterCookieDomain(current, next) {
|
|
85
|
+
if (current === void 0) return next;
|
|
86
|
+
if (current === "" || next === "") return "";
|
|
87
|
+
return next.length > current.length ? next : current;
|
|
88
|
+
}
|
|
89
|
+
function readRawCookie(name) {
|
|
90
|
+
if (typeof document === "undefined") return void 0;
|
|
91
|
+
try {
|
|
92
|
+
for (const part of document.cookie.split(";")) {
|
|
93
|
+
const raw = part.trim();
|
|
94
|
+
if (!raw.startsWith(`${name}=`)) continue;
|
|
95
|
+
return decodeURIComponent(raw.slice(name.length + 1));
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
function setRawCookie(name, value, domain, maxAgeSeconds) {
|
|
100
|
+
if (typeof document === "undefined") return false;
|
|
101
|
+
const attributes = [
|
|
102
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
103
|
+
"path=/",
|
|
104
|
+
`max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
|
105
|
+
"SameSite=Lax"
|
|
106
|
+
];
|
|
107
|
+
if (domain) attributes.push(`domain=.${domain}`);
|
|
108
|
+
if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
|
|
109
|
+
try {
|
|
110
|
+
document.cookie = attributes.join("; ");
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The broadest domain this browser will actually accept a cookie for, found by
|
|
118
|
+
* probing rather than by carrying a public-suffix list — the same trick
|
|
119
|
+
* posthog-js uses. Candidates start at the broadest (the last two labels) and
|
|
120
|
+
* narrow a label at a time, with the first that sticks winning — so
|
|
121
|
+
* `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
|
|
122
|
+
*
|
|
123
|
+
* Returns `""` (host-only cookie) for single-label hosts like `localhost` and
|
|
124
|
+
* for bare IPs, neither of which can carry a `Domain=` attribute.
|
|
125
|
+
*/
|
|
126
|
+
function probeCookieDomain() {
|
|
127
|
+
if (typeof document === "undefined" || typeof location === "undefined") return "";
|
|
128
|
+
const hostname = location.hostname;
|
|
129
|
+
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
|
|
130
|
+
const parts = hostname.split(".");
|
|
131
|
+
if (parts.length < 2) return "";
|
|
132
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
133
|
+
const candidate = parts.slice(i).join(".");
|
|
134
|
+
const probe = "__maple_probe";
|
|
135
|
+
if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
|
|
136
|
+
setRawCookie(probe, "", candidate, 0);
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
function cookieDomain() {
|
|
143
|
+
if (cookieDomainOverride !== void 0) return cookieDomainOverride;
|
|
144
|
+
if (!crossSubdomainCookie) return "";
|
|
145
|
+
if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
|
|
146
|
+
return probedCookieDomain;
|
|
147
|
+
}
|
|
148
|
+
function parseRecord(raw) {
|
|
149
|
+
if (!raw) return void 0;
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(raw);
|
|
152
|
+
if (typeof parsed.id !== "string" || typeof parsed.mintedAt !== "number") return void 0;
|
|
153
|
+
if (Date.now() - parsed.mintedAt > MAX_AGE_MS) return void 0;
|
|
154
|
+
return parsed;
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function readFromStorage() {
|
|
160
|
+
try {
|
|
161
|
+
return parseRecord(window.localStorage.getItem(STORAGE_KEY$1) ?? void 0);
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function readFromCookie() {
|
|
167
|
+
return parseRecord(readRawCookie(COOKIE_NAME));
|
|
168
|
+
}
|
|
169
|
+
function writeToStorage(record) {
|
|
170
|
+
try {
|
|
171
|
+
window.localStorage.setItem(STORAGE_KEY$1, JSON.stringify(record));
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Mirror the record into the cookie. The max-age is the id's *remaining* life,
|
|
179
|
+
* not a fresh 400 days, so re-writing it on every page load can't turn the
|
|
180
|
+
* expiry into a sliding window that never fires.
|
|
181
|
+
*/
|
|
182
|
+
function writeToCookie(record) {
|
|
183
|
+
const remainingMs = MAX_AGE_MS - (Date.now() - record.mintedAt);
|
|
184
|
+
if (remainingMs <= 0) return false;
|
|
185
|
+
return setRawCookie(COOKIE_NAME, JSON.stringify(record), cookieDomain(), remainingMs / 1e3);
|
|
186
|
+
}
|
|
187
|
+
function write(record) {
|
|
188
|
+
const inStorage = writeToStorage(record);
|
|
189
|
+
const inCookie = writeToCookie(record);
|
|
190
|
+
persisted = inStorage || inCookie;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The current visitor id, minting one on first use. `undefined` when visitor
|
|
194
|
+
* tracking is off or outside a browser.
|
|
195
|
+
*/
|
|
196
|
+
function getVisitorId() {
|
|
197
|
+
if (!enabled || typeof window === "undefined") return void 0;
|
|
198
|
+
if (cached) return cached.id;
|
|
199
|
+
const fromCookie = readFromCookie();
|
|
200
|
+
const fromStorage = readFromStorage();
|
|
201
|
+
const existing = fromCookie ?? fromStorage;
|
|
202
|
+
if (existing) {
|
|
203
|
+
cached = existing;
|
|
204
|
+
persisted = true;
|
|
205
|
+
if (!fromCookie) writeToCookie(existing);
|
|
206
|
+
if (!fromStorage || fromStorage.id !== existing.id) writeToStorage(existing);
|
|
207
|
+
return existing.id;
|
|
208
|
+
}
|
|
209
|
+
const record = {
|
|
210
|
+
id: crypto.randomUUID(),
|
|
211
|
+
mintedAt: Date.now()
|
|
212
|
+
};
|
|
213
|
+
cached = record;
|
|
214
|
+
mintedThisLoad = true;
|
|
215
|
+
write(record);
|
|
216
|
+
return record.id;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Claim the "this visitor was minted just now" flag for the session being
|
|
220
|
+
* created — drives new vs returning without a self-join. Reading it mints the
|
|
221
|
+
* id if needed, so callers get an answer consistent with `getVisitorId()`.
|
|
222
|
+
*
|
|
223
|
+
* One-shot on purpose. The flag is page-load scoped, but sessions are not: a
|
|
224
|
+
* page load can both start a session and (30 minutes idle later) rotate into a
|
|
225
|
+
* second one, and only the first of those belongs to a new visitor. The
|
|
226
|
+
* claimed value is persisted on the session record, which is also what keeps a
|
|
227
|
+
* reload from re-answering the question — see `session.ts`.
|
|
228
|
+
*/
|
|
229
|
+
function claimNewVisitor() {
|
|
230
|
+
getVisitorId();
|
|
231
|
+
if (!mintedThisLoad) return false;
|
|
232
|
+
mintedThisLoad = false;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Whether the id actually survives this page load. `false` means both stores
|
|
237
|
+
* were blocked and the id is in-memory only, so uniques would be over-counted;
|
|
238
|
+
* the metadata row carries this as `maple.visitor.persisted` so the analytics
|
|
239
|
+
* layer can flag it rather than quietly inflate.
|
|
240
|
+
*/
|
|
241
|
+
function isVisitorIdPersisted() {
|
|
242
|
+
return persisted;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Turn persistent visitor tracking on or off. Turning it off also purges any
|
|
246
|
+
* id already stored — an opt-out that leaves the identifier behind is not one.
|
|
247
|
+
*/
|
|
248
|
+
function setVisitorTracking(nextEnabled) {
|
|
249
|
+
enabled = nextEnabled;
|
|
250
|
+
if (nextEnabled) return;
|
|
251
|
+
cached = void 0;
|
|
252
|
+
persisted = false;
|
|
253
|
+
mintedThisLoad = false;
|
|
254
|
+
try {
|
|
255
|
+
window.localStorage.removeItem(STORAGE_KEY$1);
|
|
256
|
+
} catch {}
|
|
257
|
+
const domain = cookieDomain();
|
|
258
|
+
setRawCookie(COOKIE_NAME, "", domain, 0);
|
|
259
|
+
if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region ../browser-session/src/user-agent.ts
|
|
263
|
+
let memo;
|
|
264
|
+
/** Best-effort UA parse — enough to populate filterable session facets. */
|
|
265
|
+
function parseUserAgent(ua) {
|
|
266
|
+
if (memo?.ua === ua) return memo.parsed;
|
|
267
|
+
const parsed = parse(ua);
|
|
268
|
+
memo = {
|
|
269
|
+
parsed,
|
|
270
|
+
ua
|
|
271
|
+
};
|
|
272
|
+
return parsed;
|
|
273
|
+
}
|
|
274
|
+
function parse(ua) {
|
|
275
|
+
return {
|
|
276
|
+
browserName: /edg/i.test(ua) ? "Edge" : /opr|opera/i.test(ua) ? "Opera" : /chrome|crios/i.test(ua) ? "Chrome" : /firefox|fxios/i.test(ua) ? "Firefox" : /safari/i.test(ua) ? "Safari" : "Unknown",
|
|
277
|
+
osName: /windows/i.test(ua) ? "Windows" : /iphone|ipad|ios/i.test(ua) ? "iOS" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /linux/i.test(ua) ? "Linux" : "Unknown",
|
|
278
|
+
deviceType: /mobile|iphone|android.*mobile/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region ../browser-session/src/meta-row.ts
|
|
283
|
+
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
284
|
+
function formatCHDateTime(date) {
|
|
285
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
286
|
+
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Build one `/v1/sessionReplays/meta` NDJSON row. Shared by `@maple-dev/browser`
|
|
290
|
+
* and the Effect client SDK so a session looks identical no matter which SDK
|
|
291
|
+
* posted it. UA/URL facets come from the live browser globals; absent (tests,
|
|
292
|
+
* exotic embedders) they fall back to empty strings.
|
|
293
|
+
*/
|
|
294
|
+
function buildSessionMetaRow(input) {
|
|
295
|
+
const g = globalThis;
|
|
296
|
+
const userAgent = g["navigator"]?.userAgent ?? "";
|
|
297
|
+
const ua = parseUserAgent(userAgent);
|
|
298
|
+
const now = /* @__PURE__ */ new Date();
|
|
299
|
+
const location = g["window"]?.location;
|
|
300
|
+
const identity = input.identity;
|
|
301
|
+
const entryUrl = input.entry?.entryUrl ?? location?.href ?? "";
|
|
302
|
+
const referrer = input.entry?.referrer ?? "";
|
|
303
|
+
const utm = input.entry?.utm ?? {};
|
|
304
|
+
const row = {
|
|
305
|
+
session_id: input.sessionId,
|
|
306
|
+
start_time: formatCHDateTime(input.startedAt),
|
|
307
|
+
status: input.status,
|
|
308
|
+
version: input.version,
|
|
309
|
+
user_id: identity?.id ?? input.userId ?? "",
|
|
310
|
+
url_initial: location?.href ?? "",
|
|
311
|
+
user_agent: userAgent,
|
|
312
|
+
browser_name: ua.browserName,
|
|
313
|
+
os_name: ua.osName,
|
|
314
|
+
device_type: ua.deviceType,
|
|
315
|
+
service_name: input.serviceName,
|
|
316
|
+
resource_attributes: {
|
|
317
|
+
"maple.session.recorded": input.recorded ? "true" : "false",
|
|
318
|
+
...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : {},
|
|
319
|
+
...input.environment ? {
|
|
320
|
+
"deployment.environment": input.environment,
|
|
321
|
+
"deployment.environment.name": input.environment
|
|
322
|
+
} : {},
|
|
323
|
+
...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : {}
|
|
324
|
+
},
|
|
325
|
+
visitor_id: input.visitorId ?? "",
|
|
326
|
+
visitor_is_new: input.visitorIsNew ? 1 : 0,
|
|
327
|
+
user_email: (input.captureUserEmail === false ? void 0 : identity?.email) ?? "",
|
|
328
|
+
user_name: identity?.username ?? "",
|
|
329
|
+
group_id: identity?.groupId ?? "",
|
|
330
|
+
group_name: identity?.groupName ?? "",
|
|
331
|
+
user_traits: identity?.traits ?? {},
|
|
332
|
+
referrer,
|
|
333
|
+
utm_source: utm.utm_source ?? "",
|
|
334
|
+
utm_medium: utm.utm_medium ?? "",
|
|
335
|
+
utm_campaign: utm.utm_campaign ?? "",
|
|
336
|
+
utm_term: utm.utm_term ?? "",
|
|
337
|
+
utm_content: utm.utm_content ?? "",
|
|
338
|
+
host: location?.host ?? "",
|
|
339
|
+
entry_path: pathOf(entryUrl),
|
|
340
|
+
exit_path: pathOf(input.lastUrl ?? location?.href ?? ""),
|
|
341
|
+
language: g["navigator"]?.language ?? "",
|
|
342
|
+
last_activity_at: formatCHDateTime(now),
|
|
343
|
+
click_count: input.clickCount ?? 0,
|
|
344
|
+
page_views: input.pageViews ?? 0,
|
|
345
|
+
error_count: input.errorCount ?? 0
|
|
346
|
+
};
|
|
347
|
+
if (input.status === "ended") {
|
|
348
|
+
row.end_time = formatCHDateTime(now);
|
|
349
|
+
row.duration_ms = Math.max(0, now.getTime() - input.startedAt.getTime());
|
|
350
|
+
row.trace_ids = input.traceIds ? Array.from(input.traceIds) : [];
|
|
351
|
+
}
|
|
352
|
+
return row;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Pathname of a URL, without query string or hash.
|
|
356
|
+
*
|
|
357
|
+
* Query strings are the most common accidental PII carrier (`?email=`,
|
|
358
|
+
* `?token=`), and paths are the analytics dimension — nobody groups by
|
|
359
|
+
* `/pricing?ref=twitter` separately from `/pricing`.
|
|
360
|
+
*/
|
|
361
|
+
function pathOf(url) {
|
|
362
|
+
if (!url) return "";
|
|
363
|
+
try {
|
|
364
|
+
return new URL(url).pathname;
|
|
365
|
+
} catch {
|
|
366
|
+
return "";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** POST one session metadata row (NDJSON). Best-effort — never throws. */
|
|
370
|
+
async function postSessionMetaRow(endpoint, ingestKey, row, keepalive = false) {
|
|
371
|
+
await fetch(`${endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
|
|
372
|
+
method: "POST",
|
|
373
|
+
headers: {
|
|
374
|
+
Authorization: `Bearer ${ingestKey}`,
|
|
375
|
+
"content-type": "application/x-ndjson"
|
|
376
|
+
},
|
|
377
|
+
body: `${JSON.stringify(row)}\n`,
|
|
378
|
+
keepalive
|
|
379
|
+
}).catch(() => {});
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region ../browser-session/src/replay/transport.ts
|
|
383
|
+
let lastWarnAt = 0;
|
|
384
|
+
function warnDropped(what, error) {
|
|
385
|
+
const now = Date.now();
|
|
386
|
+
if (now - lastWarnAt < 3e4) return;
|
|
387
|
+
lastWarnAt = now;
|
|
388
|
+
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
389
|
+
}
|
|
390
|
+
/** gzip a byte buffer using the native CompressionStream (no library). */
|
|
391
|
+
async function gzip(bytes) {
|
|
392
|
+
const stream = new CompressionStream("gzip");
|
|
393
|
+
const writer = stream.writable.getWriter();
|
|
394
|
+
writer.write(bytes);
|
|
395
|
+
writer.close();
|
|
396
|
+
const buffer = await new Response(stream.readable).arrayBuffer();
|
|
397
|
+
return new Uint8Array(buffer);
|
|
398
|
+
}
|
|
399
|
+
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
400
|
+
async function postSessionMeta(config, row, keepalive = false) {
|
|
401
|
+
const body = `${JSON.stringify(row)}\n`;
|
|
402
|
+
await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
|
|
403
|
+
method: "POST",
|
|
404
|
+
headers: {
|
|
405
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
406
|
+
"content-type": "application/x-ndjson"
|
|
407
|
+
},
|
|
408
|
+
body,
|
|
409
|
+
keepalive
|
|
410
|
+
}).catch((error) => {
|
|
411
|
+
warnDropped("metadata POST", error);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/** POST distilled session events (NDJSON, one row per event). Best-effort. */
|
|
415
|
+
async function postSessionEvents(config, rows, keepalive = false) {
|
|
416
|
+
if (rows.length === 0) return;
|
|
417
|
+
const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
418
|
+
await fetch(`${config.endpoint}/v1/sessionEvents`, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: {
|
|
421
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
422
|
+
"content-type": "application/x-ndjson"
|
|
423
|
+
},
|
|
424
|
+
body,
|
|
425
|
+
keepalive
|
|
426
|
+
}).catch((error) => {
|
|
427
|
+
warnDropped("events POST", error);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/** PUT a gzipped rrweb event chunk. */
|
|
431
|
+
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
432
|
+
await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
|
|
433
|
+
method: "POST",
|
|
434
|
+
headers: {
|
|
435
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
436
|
+
"content-type": "application/octet-stream",
|
|
437
|
+
"x-maple-session-id": meta.sessionId,
|
|
438
|
+
"x-maple-chunk-seq": String(meta.chunkSeq),
|
|
439
|
+
"x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
|
|
440
|
+
"x-maple-event-count": String(meta.eventCount),
|
|
441
|
+
"x-maple-duration-ms": String(meta.durationMs)
|
|
442
|
+
},
|
|
443
|
+
body: gzipped,
|
|
444
|
+
keepalive
|
|
445
|
+
}).catch((error) => {
|
|
446
|
+
warnDropped("blob PUT", error);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region ../browser-session/src/replay/util.ts
|
|
451
|
+
/** Approximate byte size of an event for flush-threshold accounting. Falls back
|
|
452
|
+
* to a fixed estimate for values that can't be serialized (e.g. cycles). */
|
|
453
|
+
function approximateSize(value) {
|
|
454
|
+
try {
|
|
455
|
+
return JSON.stringify(value).length;
|
|
456
|
+
} catch {
|
|
457
|
+
return 256;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region ../browser-session/src/session.ts
|
|
462
|
+
const STORAGE_KEY = "maple.session";
|
|
463
|
+
/** Rotate the session after this much inactivity (PostHog's default). */
|
|
464
|
+
const IDLE_TIMEOUT_MS = 18e5;
|
|
465
|
+
/** Hard cap on a single session's lifetime regardless of activity. */
|
|
466
|
+
const MAX_SESSION_MS = 864e5;
|
|
467
|
+
/**
|
|
468
|
+
* The activity path runs per span creation (`getSessionId`) and per captured
|
|
469
|
+
* event (`markActivity` — every console call, keystroke, click, fetch);
|
|
470
|
+
* persisting the bump on every call would hammer sessionStorage for no benefit
|
|
471
|
+
* — rotation correctness only needs sub-idle-timeout granularity.
|
|
472
|
+
*/
|
|
473
|
+
const ACTIVITY_TOUCH_THROTTLE_MS = 5e3;
|
|
474
|
+
const UTM_KEYS = [
|
|
475
|
+
"utm_source",
|
|
476
|
+
"utm_medium",
|
|
477
|
+
"utm_campaign",
|
|
478
|
+
"utm_term",
|
|
479
|
+
"utm_content"
|
|
480
|
+
];
|
|
481
|
+
/** In-memory fallback when sessionStorage is unavailable (private mode). */
|
|
482
|
+
let ephemeral;
|
|
483
|
+
const rotationListeners = /* @__PURE__ */ new Set();
|
|
484
|
+
/**
|
|
485
|
+
* Read the entry URL/referrer/UTM of the current page.
|
|
486
|
+
*
|
|
487
|
+
* Guarded for non-DOM runtimes: `freshRecord` is reachable from `getSessionId`
|
|
488
|
+
* on the span path and from the `nextChunkSeq`/`nextMetaVersion` fallbacks, so
|
|
489
|
+
* this must not assume `location`/`document` exist.
|
|
490
|
+
*/
|
|
491
|
+
function readEntryContext() {
|
|
492
|
+
if (typeof window === "undefined" || typeof location === "undefined") return {};
|
|
493
|
+
const utm = {};
|
|
494
|
+
try {
|
|
495
|
+
const params = new URLSearchParams(location.search);
|
|
496
|
+
for (const key of UTM_KEYS) {
|
|
497
|
+
const value = params.get(key)?.trim();
|
|
498
|
+
if (value) utm[key] = value.slice(0, 128);
|
|
499
|
+
}
|
|
500
|
+
} catch {}
|
|
501
|
+
return {
|
|
502
|
+
entryUrl: location.href,
|
|
503
|
+
entryReferrer: typeof document !== "undefined" ? document.referrer : "",
|
|
504
|
+
utm,
|
|
505
|
+
lastUrl: location.href,
|
|
506
|
+
pageViews: 0,
|
|
507
|
+
clickCount: 0,
|
|
508
|
+
errorCount: 0
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function freshRecord(now) {
|
|
512
|
+
return {
|
|
513
|
+
id: crypto.randomUUID(),
|
|
514
|
+
startedAt: now,
|
|
515
|
+
lastActivityAt: now,
|
|
516
|
+
chunkSeq: 0,
|
|
517
|
+
metaVersion: 0,
|
|
518
|
+
visitorIsNew: claimNewVisitor(),
|
|
519
|
+
...readEntryContext()
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function readRecord() {
|
|
523
|
+
try {
|
|
524
|
+
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
525
|
+
if (!raw) return void 0;
|
|
526
|
+
const parsed = JSON.parse(raw);
|
|
527
|
+
if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
|
|
528
|
+
return;
|
|
529
|
+
} catch {
|
|
530
|
+
return ephemeral;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function writeRecord(record) {
|
|
534
|
+
ephemeral = record;
|
|
535
|
+
try {
|
|
536
|
+
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
537
|
+
} catch {}
|
|
538
|
+
}
|
|
539
|
+
function isSessionExpired(record, now = Date.now()) {
|
|
540
|
+
return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
|
|
541
|
+
}
|
|
542
|
+
/** Observe genuine idle/lifetime rotation. Invoked before the new record is installed. */
|
|
543
|
+
function onSessionRotate(listener) {
|
|
544
|
+
rotationListeners.add(listener);
|
|
545
|
+
return () => rotationListeners.delete(listener);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Upgrade an in-flight session written by an older SDK. The visitor feature
|
|
549
|
+
* can land while a tab still has the old record in sessionStorage; claiming
|
|
550
|
+
* newness here makes that current session the new visitor's session and
|
|
551
|
+
* consumes the one-shot claim so a later idle rotation cannot steal it.
|
|
552
|
+
*/
|
|
553
|
+
function migrateRecord(record) {
|
|
554
|
+
return record.visitorIsNew === void 0 ? {
|
|
555
|
+
...record,
|
|
556
|
+
visitorIsNew: claimNewVisitor()
|
|
557
|
+
} : record;
|
|
558
|
+
}
|
|
559
|
+
function installRotatedRecord(previous, next) {
|
|
560
|
+
if (previous) for (const listener of rotationListeners) try {
|
|
561
|
+
listener(previous, next);
|
|
562
|
+
} catch {}
|
|
563
|
+
writeRecord(next);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Resolve the active session, rotating to a fresh one if the previous session
|
|
567
|
+
* has gone idle (or hit the lifetime cap). Touches `lastActivityAt` so calling
|
|
568
|
+
* it on page load keeps a live session alive. The id is the correlation key
|
|
569
|
+
* shared by OTel traces and replay events.
|
|
570
|
+
*/
|
|
571
|
+
function getSession() {
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
const existing = readRecord();
|
|
574
|
+
if (existing && !isSessionExpired(existing, now)) {
|
|
575
|
+
const record = {
|
|
576
|
+
...migrateRecord(existing),
|
|
577
|
+
lastActivityAt: now
|
|
578
|
+
};
|
|
579
|
+
writeRecord(record);
|
|
580
|
+
return record;
|
|
581
|
+
}
|
|
582
|
+
const record = freshRecord(now);
|
|
583
|
+
installRotatedRecord(existing, record);
|
|
584
|
+
return record;
|
|
585
|
+
}
|
|
586
|
+
/** Force a new session boundary (used after a consent revoke/re-grant cycle). */
|
|
587
|
+
function rotateSession() {
|
|
588
|
+
if (typeof window === "undefined") return void 0;
|
|
589
|
+
const previous = readRecord();
|
|
590
|
+
const next = freshRecord(Date.now());
|
|
591
|
+
installRotatedRecord(previous, next);
|
|
592
|
+
return next;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Resolve the active session, rotating when the stored one has expired, and
|
|
596
|
+
* persist the activity bump only once it has gone stale by
|
|
597
|
+
* `ACTIVITY_TOUCH_THROTTLE_MS`. Shared by the two hot-path entry points so a
|
|
598
|
+
* chatty page doesn't pay a sessionStorage read *and* write per span/event.
|
|
599
|
+
*/
|
|
600
|
+
function touchSession(now) {
|
|
601
|
+
const existing = readRecord();
|
|
602
|
+
if (existing && !isSessionExpired(existing, now)) {
|
|
603
|
+
const migrated = migrateRecord(existing);
|
|
604
|
+
const touched = {
|
|
605
|
+
...migrated,
|
|
606
|
+
lastActivityAt: now
|
|
607
|
+
};
|
|
608
|
+
if (migrated !== existing || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
|
|
609
|
+
return touched;
|
|
610
|
+
}
|
|
611
|
+
const record = freshRecord(now);
|
|
612
|
+
installRotatedRecord(existing, record);
|
|
613
|
+
return record;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Mark activity, rotating first when the stored session has expired. Called per
|
|
617
|
+
* captured event, so it shares `getSessionId`'s throttled touch — callers read
|
|
618
|
+
* the returned record's `id` to detect that rotation.
|
|
619
|
+
*/
|
|
620
|
+
function markActivity() {
|
|
621
|
+
if (typeof window === "undefined") return void 0;
|
|
622
|
+
return touchSession(Date.now());
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Record a page view. Persisted on the session record rather than held in the
|
|
626
|
+
* capture loop's memory so the count survives reloads within the session — a
|
|
627
|
+
* two-page visit split by a refresh is not a bounce.
|
|
628
|
+
*/
|
|
629
|
+
function noteNavigation(url) {
|
|
630
|
+
if (typeof window === "undefined") return;
|
|
631
|
+
const now = Date.now();
|
|
632
|
+
const record = touchSession(now);
|
|
633
|
+
writeRecord({
|
|
634
|
+
...record,
|
|
635
|
+
lastUrl: url,
|
|
636
|
+
pageViews: (record.pageViews ?? 0) + 1,
|
|
637
|
+
lastActivityAt: now
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/** Accumulate interaction/error counts onto the persisted session record. */
|
|
641
|
+
function noteCounts(counts) {
|
|
642
|
+
const record = readRecord();
|
|
643
|
+
if (!record) return;
|
|
644
|
+
writeRecord({
|
|
645
|
+
...record,
|
|
646
|
+
clickCount: counts.clickCount ?? record.clickCount ?? 0,
|
|
647
|
+
errorCount: counts.errorCount ?? record.errorCount ?? 0
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The persisted session record as-is — no activity touch, no rotation. Use
|
|
652
|
+
* this to read counters when posting a metadata row; `getSession()` would
|
|
653
|
+
* rotate an idle session out from under the row being written.
|
|
654
|
+
*/
|
|
655
|
+
function peekSession() {
|
|
656
|
+
return readRecord();
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The acquisition context carried by a record. Takes the record rather than
|
|
660
|
+
* re-reading storage, because every caller already holds one — the metadata
|
|
661
|
+
* lifecycle posts from it.
|
|
662
|
+
*/
|
|
663
|
+
function entryContextOf(record) {
|
|
664
|
+
return {
|
|
665
|
+
entryUrl: record.entryUrl ?? "",
|
|
666
|
+
referrer: record.entryReferrer ?? "",
|
|
667
|
+
utm: record.utm ?? {}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Take the next replay chunk sequence number for the current session. Monotonic
|
|
672
|
+
* across reloads (persisted on the session record), so a refresh continues the
|
|
673
|
+
* sequence instead of restarting at 0 and overwriting the previous load's blobs.
|
|
674
|
+
*/
|
|
675
|
+
function nextChunkSeq() {
|
|
676
|
+
const record = readRecord() ?? freshRecord(Date.now());
|
|
677
|
+
const seq = record.chunkSeq;
|
|
678
|
+
writeRecord({
|
|
679
|
+
...record,
|
|
680
|
+
chunkSeq: seq + 1
|
|
681
|
+
});
|
|
682
|
+
return seq;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Take the next session-metadata row version for the current session.
|
|
686
|
+
* Monotonic per session across reloads, hide/resume cycles, and writers (both
|
|
687
|
+
* SDKs share the persisted counter), so `argMax(field, Version)` on the
|
|
688
|
+
* backend always resolves to the most recently posted row. Records written by
|
|
689
|
+
* older SDKs (no `metaVersion`) already posted versions 1 and 2, so the
|
|
690
|
+
* counter resumes at 3 for them; a fresh session starts at 1.
|
|
691
|
+
*/
|
|
692
|
+
function nextMetaVersion() {
|
|
693
|
+
const record = readRecord() ?? freshRecord(Date.now());
|
|
694
|
+
const version = (record.metaVersion ?? 2) + 1;
|
|
695
|
+
writeRecord({
|
|
696
|
+
...record,
|
|
697
|
+
metaVersion: version
|
|
698
|
+
});
|
|
699
|
+
return version;
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region ../browser-session/src/events-sink.ts
|
|
703
|
+
const FLUSH_INTERVAL_MS = 5e3;
|
|
704
|
+
const FLUSH_BYTES = 65536;
|
|
705
|
+
const ZERO_TRACE_ID = "00000000000000000000000000000000";
|
|
706
|
+
let traceIdProvider = () => void 0;
|
|
707
|
+
/** Wire the host SDK's active-trace-id lookup into event capture. */
|
|
708
|
+
function setActiveTraceIdProvider(provider) {
|
|
709
|
+
traceIdProvider = provider;
|
|
710
|
+
}
|
|
711
|
+
/** The trace id of the active span, or undefined when none is active. */
|
|
712
|
+
function activeTraceId() {
|
|
713
|
+
const id = traceIdProvider();
|
|
714
|
+
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The sink is a per-session singleton, published on `globalThis` rather than a
|
|
718
|
+
* module-level variable.
|
|
719
|
+
*
|
|
720
|
+
* Two consumers can end up with separate copies of this module in one page (an
|
|
721
|
+
* app bundling both `@maple-dev/browser` and the Effect SDK, or a
|
|
722
|
+
* lazily-imported replay chunk). Two sinks would mean two `seq` counters
|
|
723
|
+
* starting at 0, and `Seq` is part of the `session_events` sorting key — the
|
|
724
|
+
* rows would collide. One global owner avoids that.
|
|
725
|
+
*/
|
|
726
|
+
const SINK_KEY = "__MAPLE_SESSION_EVENT_SINK__";
|
|
727
|
+
function holder() {
|
|
728
|
+
return globalThis;
|
|
729
|
+
}
|
|
730
|
+
/** The live sink, if one has been started. */
|
|
731
|
+
function getActiveSink() {
|
|
732
|
+
return holder()[SINK_KEY]?.sink;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Start (or reuse) the distilled-event sink for a session.
|
|
736
|
+
*
|
|
737
|
+
* Runs on **every** page load, not just sampled-for-replay ones: page views and
|
|
738
|
+
* `track()` calls are the analytics substrate, and gating them behind replay
|
|
739
|
+
* sampling would make unique visitors and top pages a sample rather than a
|
|
740
|
+
* count. The rrweb recorder stays sampled — it is the expensive part.
|
|
741
|
+
*/
|
|
742
|
+
function startEventSink(config, sessionId) {
|
|
743
|
+
const existing = holder()[SINK_KEY];
|
|
744
|
+
if (existing && existing.sessionId === sessionId) return existing.sink;
|
|
745
|
+
if (existing) {
|
|
746
|
+
existing.sink.flush();
|
|
747
|
+
existing.sink.stop();
|
|
748
|
+
}
|
|
749
|
+
let buffer = [];
|
|
750
|
+
let bufferBytes = 0;
|
|
751
|
+
let seq = 0;
|
|
752
|
+
let pageViews = 0;
|
|
753
|
+
let clickCount = 0;
|
|
754
|
+
let errorCount = 0;
|
|
755
|
+
const flush = async (keepalive = false) => {
|
|
756
|
+
if (buffer.length === 0) return;
|
|
757
|
+
const batch = buffer;
|
|
758
|
+
buffer = [];
|
|
759
|
+
bufferBytes = 0;
|
|
760
|
+
await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
|
|
761
|
+
};
|
|
762
|
+
const emit = (ev) => {
|
|
763
|
+
const session = markActivity();
|
|
764
|
+
if (session && session.id !== sessionId) {
|
|
765
|
+
const nextSink = startEventSink(config, session.id);
|
|
766
|
+
if (ev.type !== "navigation") nextSink.emit(ev);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (ev.type === "navigation") {
|
|
770
|
+
pageViews++;
|
|
771
|
+
noteNavigation(ev.url ?? (typeof location !== "undefined" ? location.href : ""));
|
|
772
|
+
} else if (ev.type === "click") clickCount++;
|
|
773
|
+
else if (ev.type === "error" || ev.type === "console" && ev.level === "error") errorCount++;
|
|
774
|
+
buffer.push({
|
|
775
|
+
ev,
|
|
776
|
+
seq: seq++
|
|
777
|
+
});
|
|
778
|
+
bufferBytes += approximateSize(ev);
|
|
779
|
+
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
780
|
+
};
|
|
781
|
+
const stopNavigation = installNavigationObserver((url) => {
|
|
782
|
+
emit({
|
|
783
|
+
type: "navigation",
|
|
784
|
+
url
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
|
|
788
|
+
const sink = {
|
|
789
|
+
sessionId,
|
|
790
|
+
emit,
|
|
791
|
+
flush,
|
|
792
|
+
stop: () => {
|
|
793
|
+
clearInterval(flushTimer);
|
|
794
|
+
stopNavigation();
|
|
795
|
+
if (holder()[SINK_KEY]?.sink === sink) holder()[SINK_KEY] = void 0;
|
|
796
|
+
},
|
|
797
|
+
getPageViews: () => pageViews,
|
|
798
|
+
getClickCount: () => clickCount,
|
|
799
|
+
getErrorCount: () => errorCount,
|
|
800
|
+
ignoreUrl: (url) => url.startsWith(`${config.endpoint}/v1/`)
|
|
801
|
+
};
|
|
802
|
+
holder()[SINK_KEY] = {
|
|
803
|
+
sessionId,
|
|
804
|
+
sink
|
|
805
|
+
};
|
|
806
|
+
drainPending(sink);
|
|
807
|
+
return sink;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Watch page views: the initial load plus every SPA navigation (history
|
|
811
|
+
* pushState/replaceState, popstate, hashchange).
|
|
812
|
+
*/
|
|
813
|
+
function installNavigationObserver(onNavigate) {
|
|
814
|
+
if (typeof window === "undefined" || typeof history === "undefined") return () => {};
|
|
815
|
+
let lastUrl = "";
|
|
816
|
+
const emitNav = () => {
|
|
817
|
+
const url = location.href;
|
|
818
|
+
if (url === lastUrl) return;
|
|
819
|
+
lastUrl = url;
|
|
820
|
+
onNavigate(url);
|
|
821
|
+
};
|
|
822
|
+
emitNav();
|
|
823
|
+
const origPush = history.pushState;
|
|
824
|
+
const origReplace = history.replaceState;
|
|
825
|
+
history.pushState = function(...args) {
|
|
826
|
+
const result = origPush.apply(this, args);
|
|
827
|
+
emitNav();
|
|
828
|
+
return result;
|
|
829
|
+
};
|
|
830
|
+
history.replaceState = function(...args) {
|
|
831
|
+
const result = origReplace.apply(this, args);
|
|
832
|
+
emitNav();
|
|
833
|
+
return result;
|
|
834
|
+
};
|
|
835
|
+
window.addEventListener("popstate", emitNav);
|
|
836
|
+
window.addEventListener("hashchange", emitNav);
|
|
837
|
+
return () => {
|
|
838
|
+
history.pushState = origPush;
|
|
839
|
+
history.replaceState = origReplace;
|
|
840
|
+
window.removeEventListener("popstate", emitNav);
|
|
841
|
+
window.removeEventListener("hashchange", emitNav);
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const PENDING_KEY = "__MAPLE_SESSION_EVENT_PENDING__";
|
|
845
|
+
const MAX_PENDING_EVENTS = 100;
|
|
846
|
+
const MAX_PENDING_BYTES = 65536;
|
|
847
|
+
function pending() {
|
|
848
|
+
const global = globalThis;
|
|
849
|
+
let state = global[PENDING_KEY];
|
|
850
|
+
if (!state) {
|
|
851
|
+
state = {
|
|
852
|
+
events: [],
|
|
853
|
+
bytes: 0,
|
|
854
|
+
warned: false
|
|
855
|
+
};
|
|
856
|
+
global[PENDING_KEY] = state;
|
|
857
|
+
}
|
|
858
|
+
return state;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Buffer an event until a sink exists. Drops oldest-first at the cap: an app
|
|
862
|
+
* that calls `track()` in a loop before init should not be able to grow this
|
|
863
|
+
* without bound.
|
|
864
|
+
*/
|
|
865
|
+
function queuePending(ev) {
|
|
866
|
+
const state = pending();
|
|
867
|
+
state.events.push(ev);
|
|
868
|
+
state.bytes += approximateSize(ev);
|
|
869
|
+
while (state.events.length > MAX_PENDING_EVENTS || state.bytes > MAX_PENDING_BYTES && state.events.length > 1) {
|
|
870
|
+
const dropped = state.events.shift();
|
|
871
|
+
if (!dropped) break;
|
|
872
|
+
state.bytes -= approximateSize(dropped);
|
|
873
|
+
if (!state.warned) {
|
|
874
|
+
state.warned = true;
|
|
875
|
+
console.warn("[maple] dropping session events queued before init — call Maple.init/identify earlier, or track() less before the SDK is ready.");
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/** Discard events captured before a consent-gated SDK was configured. */
|
|
880
|
+
function clearPendingEvents() {
|
|
881
|
+
const state = pending();
|
|
882
|
+
state.events = [];
|
|
883
|
+
state.bytes = 0;
|
|
884
|
+
}
|
|
885
|
+
/** Hand everything queued before init to the sink, oldest first. */
|
|
886
|
+
function drainPending(sink) {
|
|
887
|
+
const state = pending();
|
|
888
|
+
if (state.events.length === 0) return;
|
|
889
|
+
const queued = state.events;
|
|
890
|
+
state.events = [];
|
|
891
|
+
state.bytes = 0;
|
|
892
|
+
for (const ev of queued) sink.emit(ev);
|
|
893
|
+
}
|
|
894
|
+
/** Map an internal event to the snake_case ingest row (org_id is added server-side). */
|
|
895
|
+
function toRow(sessionId, ev, seq) {
|
|
896
|
+
return {
|
|
897
|
+
session_id: sessionId,
|
|
898
|
+
timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
|
|
899
|
+
seq,
|
|
900
|
+
type: ev.type,
|
|
901
|
+
url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
|
|
902
|
+
trace_id: ev.traceId ?? activeTraceId() ?? "",
|
|
903
|
+
level: ev.level ?? "",
|
|
904
|
+
message: ev.message ?? "",
|
|
905
|
+
target_selector: ev.targetSelector ?? "",
|
|
906
|
+
target_text: ev.targetText ?? "",
|
|
907
|
+
net_method: ev.net?.method ?? "",
|
|
908
|
+
net_url: ev.net?.url ?? "",
|
|
909
|
+
net_status: ev.net?.status ?? 0,
|
|
910
|
+
net_duration_ms: ev.net?.durationMs ?? 0,
|
|
911
|
+
error_stack: ev.errorStack ?? "",
|
|
912
|
+
attributes: ev.attrs ?? {}
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region ../browser-session/src/session-lifecycle.ts
|
|
917
|
+
/**
|
|
918
|
+
* How often a visible tab re-posts its `active` row.
|
|
919
|
+
*
|
|
920
|
+
* This is what makes exit page, page views and duration survive a tab killed
|
|
921
|
+
* without an unload beacon — otherwise the only surviving row is the v1 row
|
|
922
|
+
* posted at session start, whose counters are all zero and therefore read as a
|
|
923
|
+
* bounce. 60s is the floor worth using: the table is a ReplacingMergeTree, so
|
|
924
|
+
* every heartbeat is an unmerged part until the next merge. Billing meters only
|
|
925
|
+
* `version == 1` rows, so heartbeats do not double-bill.
|
|
926
|
+
*/
|
|
927
|
+
const HEARTBEAT_INTERVAL_MS = 6e4;
|
|
928
|
+
/**
|
|
929
|
+
* Drive one session's metadata lifecycle. Returns undefined outside a browser;
|
|
930
|
+
* sampling and consent are the caller's decisions.
|
|
931
|
+
*/
|
|
932
|
+
function startSessionLifecycle(options, hooks) {
|
|
933
|
+
if (typeof window === "undefined") return void 0;
|
|
934
|
+
let current = getSession();
|
|
935
|
+
let stopped = false;
|
|
936
|
+
/** Whether a run is live: capture started, `active` posted, heartbeat armed. */
|
|
937
|
+
let running = false;
|
|
938
|
+
let heartbeat;
|
|
939
|
+
let clickCountBase = 0;
|
|
940
|
+
let errorCountBase = 0;
|
|
941
|
+
let sinkClickCountAtStart = 0;
|
|
942
|
+
let sinkErrorCountAtStart = 0;
|
|
943
|
+
/**
|
|
944
|
+
* The persisted record of the session this lifecycle owns.
|
|
945
|
+
*
|
|
946
|
+
* `current` fixes only the identity of the run — counters, `lastUrl` and
|
|
947
|
+
* `lastActivityAt` move underneath it as the page is used, so a row built
|
|
948
|
+
* from the captured object alone reports the exit path and page views of the
|
|
949
|
+
* session's *first* page forever. Reading storage back per row keeps them
|
|
950
|
+
* live, and the id check keeps a rotation from attributing the incoming
|
|
951
|
+
* session's context to the outgoing session's `ended` row.
|
|
952
|
+
*/
|
|
953
|
+
const liveRecord = () => {
|
|
954
|
+
const stored = peekSession();
|
|
955
|
+
return stored?.id === current.id ? stored : current;
|
|
956
|
+
};
|
|
957
|
+
/**
|
|
958
|
+
* Re-baseline the cumulative counters against the persisted record.
|
|
959
|
+
*
|
|
960
|
+
* The record is what carries counts across a reload and across a hide/resume
|
|
961
|
+
* cycle (each `ended` row writes them back via `noteCounts`), while live
|
|
962
|
+
* capture restarts from zero every run — so a run's row is the persisted base
|
|
963
|
+
* plus what capture has seen since this run began.
|
|
964
|
+
*/
|
|
965
|
+
const rebaseCounts = (record) => {
|
|
966
|
+
clickCountBase = record.clickCount ?? 0;
|
|
967
|
+
errorCountBase = record.errorCount ?? 0;
|
|
968
|
+
const sink = getActiveSink();
|
|
969
|
+
sinkClickCountAtStart = sink?.getClickCount() ?? 0;
|
|
970
|
+
sinkErrorCountAtStart = sink?.getErrorCount() ?? 0;
|
|
971
|
+
};
|
|
972
|
+
const countsFor = (record) => {
|
|
973
|
+
const sink = getActiveSink();
|
|
974
|
+
const sinkMatches = sink !== void 0 && sink.sessionId === record.id;
|
|
975
|
+
const clicks = hooks.clicksSinceStart?.() ?? (sinkMatches ? sink.getClickCount() - sinkClickCountAtStart : 0);
|
|
976
|
+
const errors = sinkMatches ? sink.getErrorCount() - sinkErrorCountAtStart : 0;
|
|
977
|
+
const counts = {
|
|
978
|
+
clickCount: clickCountBase + Math.max(0, clicks),
|
|
979
|
+
errorCount: errorCountBase + Math.max(0, errors),
|
|
980
|
+
pageViews: record.pageViews ?? (sinkMatches ? sink.getPageViews() : 0)
|
|
981
|
+
};
|
|
982
|
+
if (peekSession()?.id === record.id) noteCounts({
|
|
983
|
+
clickCount: counts.clickCount,
|
|
984
|
+
errorCount: counts.errorCount
|
|
985
|
+
});
|
|
986
|
+
return counts;
|
|
987
|
+
};
|
|
988
|
+
const post = (status, keepalive) => {
|
|
989
|
+
const record = liveRecord();
|
|
990
|
+
const counts = countsFor(record);
|
|
991
|
+
hooks.post(buildSessionMetaRow({
|
|
992
|
+
sessionId: record.id,
|
|
993
|
+
startedAt: new Date(record.startedAt),
|
|
994
|
+
version: nextMetaVersion(),
|
|
995
|
+
status,
|
|
996
|
+
serviceName: options.serviceName,
|
|
997
|
+
identity: options.getIdentity?.(),
|
|
998
|
+
captureUserEmail: options.captureUserEmail,
|
|
999
|
+
environment: options.environment,
|
|
1000
|
+
serviceVersion: options.serviceVersion,
|
|
1001
|
+
visitorId: getVisitorId(),
|
|
1002
|
+
visitorIsNew: record.visitorIsNew === true,
|
|
1003
|
+
visitorIdPersisted: isVisitorIdPersisted(),
|
|
1004
|
+
entry: entryContextOf(record),
|
|
1005
|
+
lastUrl: record.lastUrl,
|
|
1006
|
+
clickCount: counts.clickCount,
|
|
1007
|
+
pageViews: counts.pageViews,
|
|
1008
|
+
errorCount: counts.errorCount,
|
|
1009
|
+
traceIds: status === "ended" ? options.getTraceIds?.(record.id) : void 0,
|
|
1010
|
+
recorded: hooks.recorded
|
|
1011
|
+
}), keepalive);
|
|
1012
|
+
};
|
|
1013
|
+
const stopHeartbeat = () => {
|
|
1014
|
+
if (heartbeat === void 0) return;
|
|
1015
|
+
clearInterval(heartbeat);
|
|
1016
|
+
heartbeat = void 0;
|
|
1017
|
+
};
|
|
1018
|
+
const startRun = () => {
|
|
1019
|
+
if (stopped || running) return;
|
|
1020
|
+
running = true;
|
|
1021
|
+
const record = liveRecord();
|
|
1022
|
+
rebaseCounts(record);
|
|
1023
|
+
hooks.onStart?.(record);
|
|
1024
|
+
post("active", false);
|
|
1025
|
+
heartbeat = setInterval(() => {
|
|
1026
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
|
1027
|
+
if (isSessionExpired(liveRecord())) {
|
|
1028
|
+
endRun({
|
|
1029
|
+
flush: true,
|
|
1030
|
+
keepalive: false
|
|
1031
|
+
});
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
post("active", false);
|
|
1035
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1036
|
+
};
|
|
1037
|
+
const endRun = (suspend) => {
|
|
1038
|
+
if (!running) return;
|
|
1039
|
+
running = false;
|
|
1040
|
+
stopHeartbeat();
|
|
1041
|
+
if (suspend.flush) post("ended", suspend.keepalive);
|
|
1042
|
+
return hooks.onSuspend?.(suspend);
|
|
1043
|
+
};
|
|
1044
|
+
const stopRotationListener = onSessionRotate((previous, next) => {
|
|
1045
|
+
if (stopped || previous.id !== current.id) return;
|
|
1046
|
+
endRun({
|
|
1047
|
+
flush: true,
|
|
1048
|
+
keepalive: false
|
|
1049
|
+
});
|
|
1050
|
+
current = next;
|
|
1051
|
+
hooks.onSessionChange?.(next.id);
|
|
1052
|
+
queueMicrotask(() => startRun());
|
|
1053
|
+
});
|
|
1054
|
+
const onPageHide = () => void endRun({
|
|
1055
|
+
flush: true,
|
|
1056
|
+
keepalive: true
|
|
1057
|
+
});
|
|
1058
|
+
const onVisibilityChange = () => {
|
|
1059
|
+
const doc = globalThis["document"];
|
|
1060
|
+
if (!doc) return;
|
|
1061
|
+
if (doc.visibilityState === "hidden") {
|
|
1062
|
+
endRun({
|
|
1063
|
+
flush: true,
|
|
1064
|
+
keepalive: true
|
|
1065
|
+
});
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (isSessionExpired(liveRecord())) {
|
|
1069
|
+
getSession();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
startRun();
|
|
1073
|
+
};
|
|
1074
|
+
const visibilityTarget = typeof document !== "undefined" && typeof document.addEventListener === "function" ? document : typeof globalThis.addEventListener === "function" ? globalThis : void 0;
|
|
1075
|
+
const pageHideTarget = typeof globalThis.addEventListener === "function" ? globalThis : void 0;
|
|
1076
|
+
startRun();
|
|
1077
|
+
visibilityTarget?.addEventListener("visibilitychange", onVisibilityChange);
|
|
1078
|
+
pageHideTarget?.addEventListener("pagehide", onPageHide);
|
|
1079
|
+
return {
|
|
1080
|
+
get sessionId() {
|
|
1081
|
+
return current.id;
|
|
1082
|
+
},
|
|
1083
|
+
shutdown: async (shutdownOptions) => {
|
|
1084
|
+
if (stopped) return;
|
|
1085
|
+
stopped = true;
|
|
1086
|
+
stopRotationListener();
|
|
1087
|
+
visibilityTarget?.removeEventListener("visibilitychange", onVisibilityChange);
|
|
1088
|
+
pageHideTarget?.removeEventListener("pagehide", onPageHide);
|
|
1089
|
+
const flush = shutdownOptions?.flush ?? true;
|
|
1090
|
+
await endRun({
|
|
1091
|
+
flush,
|
|
1092
|
+
keepalive: flush
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region ../browser-session/src/sink.ts
|
|
1099
|
+
const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
|
|
1100
|
+
const observedTraceIdsBySession = /* @__PURE__ */ new Map();
|
|
1101
|
+
/** Record a trace id seen during the session. Idempotent per id. */
|
|
1102
|
+
function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
|
|
1103
|
+
if (!sessionId) return;
|
|
1104
|
+
let ids = observedTraceIdsBySession.get(sessionId);
|
|
1105
|
+
if (!ids) {
|
|
1106
|
+
ids = /* @__PURE__ */ new Set();
|
|
1107
|
+
observedTraceIdsBySession.set(sessionId, ids);
|
|
1108
|
+
}
|
|
1109
|
+
ids.add(traceId);
|
|
1110
|
+
}
|
|
1111
|
+
function getObservedTraceIds(sessionId = readSessionSink()?.sessionId) {
|
|
1112
|
+
return sessionId ? Array.from(observedTraceIdsBySession.get(sessionId) ?? []) : [];
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Drop the trace ids of every session but `sessionId`. Called when the sink is
|
|
1116
|
+
* republished under a rotated id — the outgoing session's `ended` row, the only
|
|
1117
|
+
* reader of its ids, has already been built by then. Without this a tab left
|
|
1118
|
+
* open for a day accumulates one id Set per 30-minute rotation, forever.
|
|
1119
|
+
*/
|
|
1120
|
+
function forgetOtherSessions(sessionId) {
|
|
1121
|
+
for (const key of observedTraceIdsBySession.keys()) if (key !== sessionId) observedTraceIdsBySession.delete(key);
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Publish the session sink on `globalThis` so other tracers in the page can
|
|
1125
|
+
* attach their trace ids to the active replay session without a direct
|
|
1126
|
+
* dependency on the publishing SDK. Reads are lazy/per-span on the consumer
|
|
1127
|
+
* side, so init ordering between SDKs does not matter.
|
|
1128
|
+
*/
|
|
1129
|
+
function publishSessionSink(sessionId) {
|
|
1130
|
+
globalThis[SESSION_SINK_KEY] = {
|
|
1131
|
+
sessionId,
|
|
1132
|
+
recordTraceId: (traceId) => recordTraceId(traceId, sessionId)
|
|
1133
|
+
};
|
|
1134
|
+
forgetOtherSessions(sessionId);
|
|
1135
|
+
}
|
|
1136
|
+
/** Remove a sink published by this SDK runtime without clobbering a newer one. */
|
|
1137
|
+
function clearSessionSink(sessionId) {
|
|
1138
|
+
const owner = globalThis;
|
|
1139
|
+
const current = owner[SESSION_SINK_KEY];
|
|
1140
|
+
if (!current || sessionId !== void 0 && current.sessionId !== sessionId) return;
|
|
1141
|
+
delete owner[SESSION_SINK_KEY];
|
|
1142
|
+
observedTraceIdsBySession.delete(current.sessionId);
|
|
1143
|
+
}
|
|
1144
|
+
/** Look up the published sink, if any page-level replay session is active. */
|
|
1145
|
+
function readSessionSink() {
|
|
1146
|
+
return globalThis[SESSION_SINK_KEY];
|
|
1147
|
+
}
|
|
1148
|
+
//#endregion
|
|
1149
|
+
export { setVisitorTracking as S, gzip as _, recordTraceId as a, postSessionMetaRow as b, clearPendingEvents as c, setActiveTraceIdProvider as d, startEventSink as f, rotateSession as g, nextChunkSeq as h, readSessionSink as i, getActiveSink as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, activeTraceId as s, clearSessionSink as t, queuePending as u, postSessionBlob as v, configureVisitorCookie as x, postSessionMeta as y };
|