@maple-dev/browser 0.4.0 → 0.8.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 +1 -1
- package/README.md +19 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +46 -10
- package/dist/{replay-session-B_ACRogX.mjs → replay-session-BYZfi3iZ.mjs} +44 -107
- package/dist/{sink-DHTfvFgl.mjs → sink-D9w1kg0Q.mjs} +386 -111
- package/package.json +17 -2
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -33,10 +33,28 @@ That single call:
|
|
|
33
33
|
gzipping them with the native `CompressionStream`, and uploading to
|
|
34
34
|
`POST /v1/sessionReplays/blob`. rrweb ships in a lazy code-split chunk loaded
|
|
35
35
|
only once a session is sampled in, so a `sampleRate` below 1 costs the
|
|
36
|
-
unsampled visitors nothing beyond the
|
|
36
|
+
unsampled visitors nothing beyond the base SDK (see [Bundle size](#bundle-size));
|
|
37
37
|
- writes session metadata at start (`active`) and on page hide (`ended`),
|
|
38
38
|
including the trace ids observed during the session.
|
|
39
39
|
|
|
40
|
+
## Bundle size
|
|
41
|
+
|
|
42
|
+
Bundled, minified and gzipped, as your bundler would ship it:
|
|
43
|
+
|
|
44
|
+
| | gzipped | what it is |
|
|
45
|
+
| ---------------- | ------- | --------------------------------------------------------- |
|
|
46
|
+
| **eager** | ~32 kB | every page load, before any sampling decision |
|
|
47
|
+
| ↳ our code alone | ~3.5 kB | the marginal cost if your app already ships OpenTelemetry |
|
|
48
|
+
| **lazy** | ~61 kB | rrweb — downloaded only by sessions sampled into replay |
|
|
49
|
+
|
|
50
|
+
The eager figure is ~90% OpenTelemetry. If your app already uses the OTel web
|
|
51
|
+
SDK, your bundler should dedupe it and you pay closer to the second row; if it
|
|
52
|
+
doesn't dedupe, you will ship two copies, so pin matching versions.
|
|
53
|
+
|
|
54
|
+
Run `bun run size` in this package for the current numbers. It fails past a
|
|
55
|
+
budget, so a regression has to be argued for in review rather than discovered
|
|
56
|
+
in production.
|
|
57
|
+
|
|
40
58
|
## Identifying users
|
|
41
59
|
|
|
42
60
|
Pass `userId` to `init()` when you already know the signed-in user, or call
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region ../browser-session/src/identity.d.ts
|
|
1
|
+
//#region ../browser-session/src/identity/identity.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* Who the current visitor is.
|
|
4
4
|
*
|
|
@@ -26,7 +26,7 @@ interface MapleIdentity {
|
|
|
26
26
|
*/
|
|
27
27
|
type IdentifyInput = string | null | undefined | MapleIdentity;
|
|
28
28
|
//#endregion
|
|
29
|
-
//#region ../browser-session/src/track.d.ts
|
|
29
|
+
//#region ../browser-session/src/events/track.d.ts
|
|
30
30
|
/** Properties a host app may attach to a custom event. */
|
|
31
31
|
type TrackProps = Readonly<Record<string, unknown>>;
|
|
32
32
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as setActiveTraceIdProvider, T as setVisitorTracking, a as recordTraceId, b as sdkHint, c as getActiveSink, d as getSession, g as SDK_HINT_HEADER, h as postSessionMetaRow, i as readSessionSink, l as queuePending, m as rotateSession, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as startEventSink, w as configureVisitorCookie } from "./sink-D9w1kg0Q.mjs";
|
|
2
2
|
import { trace } from "@opentelemetry/api";
|
|
3
3
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
4
4
|
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
|
@@ -7,7 +7,7 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
|
7
7
|
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
8
8
|
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
|
|
9
9
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
10
|
-
//#region ../browser-session/src/consent.ts
|
|
10
|
+
//#region ../browser-session/src/identity/consent.ts
|
|
11
11
|
/**
|
|
12
12
|
* Consent gating for session capture.
|
|
13
13
|
*
|
|
@@ -27,7 +27,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic
|
|
|
27
27
|
*/
|
|
28
28
|
/**
|
|
29
29
|
* Consent lives on `globalThis`, for the same reason the event sink does
|
|
30
|
-
* (`events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
|
|
30
|
+
* (`events/events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
|
|
31
31
|
* Effect SDK gets **two copies** of this module, and per-copy state would mean
|
|
32
32
|
* `setConsent(true)` on one never releases the other copy's exporters — or, the
|
|
33
33
|
* other way round, one copy capturing while the other believes consent is
|
|
@@ -120,7 +120,7 @@ function mayPersistIdentifier() {
|
|
|
120
120
|
return true;
|
|
121
121
|
}
|
|
122
122
|
//#endregion
|
|
123
|
-
//#region ../browser-session/src/identity.ts
|
|
123
|
+
//#region ../browser-session/src/identity/identity.ts
|
|
124
124
|
/**
|
|
125
125
|
* Trait caps. The warehouse column is `Map(String, String)` — plain keys
|
|
126
126
|
* precisely because they are arbitrary — so the cost of a unique key per user
|
|
@@ -189,7 +189,7 @@ function normalizeIdentity(input) {
|
|
|
189
189
|
return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
|
|
190
190
|
}
|
|
191
191
|
//#endregion
|
|
192
|
-
//#region ../browser-session/src/metadata-session.ts
|
|
192
|
+
//#region ../browser-session/src/session/metadata-session.ts
|
|
193
193
|
/**
|
|
194
194
|
* Metadata-only session lifecycle used whenever rrweb is disabled or unsampled.
|
|
195
195
|
* It is the shared lifecycle driver with no capture of its own: the distilled
|
|
@@ -200,7 +200,7 @@ function startMetadataSession(options) {
|
|
|
200
200
|
return startSessionLifecycle(options, {
|
|
201
201
|
recorded: false,
|
|
202
202
|
post: (row, keepalive) => {
|
|
203
|
-
postSessionMetaRow(options
|
|
203
|
+
postSessionMetaRow(options, row, keepalive);
|
|
204
204
|
},
|
|
205
205
|
onSuspend: ({ flush, keepalive }) => {
|
|
206
206
|
if (flush && keepalive) getActiveSink()?.flush(true);
|
|
@@ -209,7 +209,7 @@ function startMetadataSession(options) {
|
|
|
209
209
|
});
|
|
210
210
|
}
|
|
211
211
|
//#endregion
|
|
212
|
-
//#region ../browser-session/src/track.ts
|
|
212
|
+
//#region ../browser-session/src/events/track.ts
|
|
213
213
|
/**
|
|
214
214
|
* Caps mirroring what the ingest gateway enforces. Applying them here too means
|
|
215
215
|
* an over-sized event is trimmed before it costs bandwidth, and the developer
|
|
@@ -324,6 +324,11 @@ function resolveConfig(config) {
|
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
326
|
//#endregion
|
|
327
|
+
//#region src/version.ts
|
|
328
|
+
const SDK_VERSION = "0.8.0";
|
|
329
|
+
/** The `x-maple-sdk` value this build sends. */
|
|
330
|
+
const SDK_NAME = "maple-browser";
|
|
331
|
+
//#endregion
|
|
327
332
|
//#region src/tracing.ts
|
|
328
333
|
/**
|
|
329
334
|
* Captures every span's trace id into the session sink. Lightweight — runs
|
|
@@ -381,6 +386,15 @@ var ConsentSpanExporter = class {
|
|
|
381
386
|
}
|
|
382
387
|
};
|
|
383
388
|
/**
|
|
389
|
+
* How long a span may sit in the batch queue before export.
|
|
390
|
+
*
|
|
391
|
+
* Shorter than OTel's 5s default: in a browser the queue is only as durable as
|
|
392
|
+
* the tab, and the unload flush below is a best-effort catch rather than a
|
|
393
|
+
* guarantee (a crashed or killed tab fires neither event). 2s trades a few more
|
|
394
|
+
* requests for a materially smaller loss window.
|
|
395
|
+
*/
|
|
396
|
+
const EXPORT_INTERVAL_MS = 2e3;
|
|
397
|
+
/**
|
|
384
398
|
* Set up browser OTel tracing exporting to Maple's ingest. When
|
|
385
399
|
* `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
|
|
386
400
|
* their trace ids feed the session. Disable it when an external tracer (e.g.
|
|
@@ -410,15 +424,33 @@ function setupTracing(config) {
|
|
|
410
424
|
}
|
|
411
425
|
const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
|
|
412
426
|
url: `${config.endpoint}/v1/traces`,
|
|
413
|
-
headers: {
|
|
427
|
+
headers: {
|
|
428
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
429
|
+
[SDK_HINT_HEADER]: sdkHint(SDK_NAME, SDK_VERSION)
|
|
430
|
+
}
|
|
414
431
|
}));
|
|
415
432
|
const provider = new WebTracerProvider({
|
|
416
433
|
resource: resourceFromAttributes(attributes),
|
|
417
|
-
spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter)]
|
|
434
|
+
spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
|
|
418
435
|
});
|
|
419
436
|
provider.register();
|
|
437
|
+
const onExit = () => {
|
|
438
|
+
provider.forceFlush().catch(() => {});
|
|
439
|
+
};
|
|
440
|
+
const onVisibilityChange = () => {
|
|
441
|
+
if (document.visibilityState === "hidden") onExit();
|
|
442
|
+
};
|
|
443
|
+
const canListen = typeof document !== "undefined" && typeof document.addEventListener === "function";
|
|
444
|
+
if (canListen) {
|
|
445
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
446
|
+
window.addEventListener("pagehide", onExit);
|
|
447
|
+
}
|
|
420
448
|
const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
|
|
421
449
|
return async () => {
|
|
450
|
+
if (canListen) {
|
|
451
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
452
|
+
window.removeEventListener("pagehide", onExit);
|
|
453
|
+
}
|
|
422
454
|
unregisterInstrumentations?.();
|
|
423
455
|
await provider.shutdown();
|
|
424
456
|
};
|
|
@@ -428,6 +460,8 @@ function escapeRegExp(value) {
|
|
|
428
460
|
}
|
|
429
461
|
//#endregion
|
|
430
462
|
//#region src/init.ts
|
|
463
|
+
/** `x-maple-sdk` value for every request this build makes to ingest. */
|
|
464
|
+
const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
|
|
431
465
|
let active;
|
|
432
466
|
let activeConfig;
|
|
433
467
|
/**
|
|
@@ -461,6 +495,7 @@ function init(rawConfig) {
|
|
|
461
495
|
const sink = startEventSink({
|
|
462
496
|
endpoint: config.endpoint,
|
|
463
497
|
ingestKey: config.ingestKey,
|
|
498
|
+
sdk: SDK_HINT,
|
|
464
499
|
maskAllInputs: config.maskAllInputs,
|
|
465
500
|
maskAllText: config.maskAllText
|
|
466
501
|
}, session.id);
|
|
@@ -468,6 +503,7 @@ function init(rawConfig) {
|
|
|
468
503
|
const shared = {
|
|
469
504
|
endpoint: config.endpoint,
|
|
470
505
|
ingestKey: config.ingestKey,
|
|
506
|
+
sdk: SDK_HINT,
|
|
471
507
|
serviceName: config.serviceName,
|
|
472
508
|
environment: config.environment,
|
|
473
509
|
serviceVersion: config.serviceVersion,
|
|
@@ -494,7 +530,7 @@ function init(rawConfig) {
|
|
|
494
530
|
runtime = next;
|
|
495
531
|
const ownGeneration = ++generation;
|
|
496
532
|
const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
|
|
497
|
-
next.replayPending = import("./replay-session-
|
|
533
|
+
next.replayPending = import("./replay-session-BYZfi3iZ.mjs").then(({ startReplaySession }) => {
|
|
498
534
|
if (stale()) return;
|
|
499
535
|
next.replay = startReplaySession({
|
|
500
536
|
...shared,
|
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { S as activeTraceId, _ as gzip, f as markActivity, n as getObservedTraceIds, o as startSessionLifecycle, p as nextChunkSeq, r as publishSessionSink, u as startEventSink, v as postSessionBlob, x as safeEmit, y as postSessionMeta } from "./sink-D9w1kg0Q.mjs";
|
|
2
2
|
import { record } from "rrweb";
|
|
3
|
-
//#region ../browser-session/src/replay/capture/shared.ts
|
|
4
|
-
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
5
|
-
function safeEmit(emit, ev) {
|
|
6
|
-
try {
|
|
7
|
-
emit(ev);
|
|
8
|
-
} catch {}
|
|
9
|
-
}
|
|
10
|
-
//#endregion
|
|
11
3
|
//#region ../browser-session/src/replay/capture/console.ts
|
|
12
4
|
const LEVELS = [
|
|
13
5
|
"log",
|
|
@@ -56,84 +48,6 @@ function formatArgs(args) {
|
|
|
56
48
|
return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
|
|
57
49
|
}
|
|
58
50
|
//#endregion
|
|
59
|
-
//#region ../browser-session/src/replay/capture/errors.ts
|
|
60
|
-
const MAX_STACK = 4e3;
|
|
61
|
-
/** Capture uncaught errors + unhandled promise rejections as session events. */
|
|
62
|
-
function installErrorCapture(emit) {
|
|
63
|
-
const onError = (event) => {
|
|
64
|
-
safeEmit(emit, {
|
|
65
|
-
type: "error",
|
|
66
|
-
level: "error",
|
|
67
|
-
message: event.message || String(event.error ?? "Error"),
|
|
68
|
-
errorStack: truncate(event.error?.stack),
|
|
69
|
-
traceId: activeTraceId()
|
|
70
|
-
});
|
|
71
|
-
};
|
|
72
|
-
const onRejection = (event) => {
|
|
73
|
-
const reason = event.reason;
|
|
74
|
-
safeEmit(emit, {
|
|
75
|
-
type: "error",
|
|
76
|
-
level: "error",
|
|
77
|
-
message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
|
|
78
|
-
errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
|
|
79
|
-
traceId: activeTraceId()
|
|
80
|
-
});
|
|
81
|
-
};
|
|
82
|
-
window.addEventListener("error", onError);
|
|
83
|
-
window.addEventListener("unhandledrejection", onRejection);
|
|
84
|
-
return () => {
|
|
85
|
-
window.removeEventListener("error", onError);
|
|
86
|
-
window.removeEventListener("unhandledrejection", onRejection);
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
function truncate(stack) {
|
|
90
|
-
if (!stack) return void 0;
|
|
91
|
-
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
92
|
-
}
|
|
93
|
-
//#endregion
|
|
94
|
-
//#region ../browser-session/src/replay/capture/interactions.ts
|
|
95
|
-
const MAX_TEXT = 120;
|
|
96
|
-
/**
|
|
97
|
-
* Capture clicks and input events as session events. Listens in the capture
|
|
98
|
-
* phase so it sees interactions even when the host app calls
|
|
99
|
-
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
100
|
-
* element. Click target text is omitted when `maskAllText` is set.
|
|
101
|
-
*/
|
|
102
|
-
function installInteractionCapture(emit, maskAllText) {
|
|
103
|
-
const onClick = (event) => {
|
|
104
|
-
const target = event.target;
|
|
105
|
-
if (!(target instanceof Element)) return;
|
|
106
|
-
safeEmit(emit, {
|
|
107
|
-
type: "click",
|
|
108
|
-
targetSelector: selectorOf(target),
|
|
109
|
-
targetText: maskAllText ? void 0 : textOf(target)
|
|
110
|
-
});
|
|
111
|
-
};
|
|
112
|
-
const onInput = (event) => {
|
|
113
|
-
const target = event.target;
|
|
114
|
-
if (!(target instanceof Element)) return;
|
|
115
|
-
safeEmit(emit, {
|
|
116
|
-
type: "input",
|
|
117
|
-
targetSelector: selectorOf(target)
|
|
118
|
-
});
|
|
119
|
-
};
|
|
120
|
-
document.addEventListener("click", onClick, true);
|
|
121
|
-
document.addEventListener("input", onInput, true);
|
|
122
|
-
return () => {
|
|
123
|
-
document.removeEventListener("click", onClick, true);
|
|
124
|
-
document.removeEventListener("input", onInput, true);
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
/** A short, human-readable selector: tag + #id + .first-class. */
|
|
128
|
-
function selectorOf(el) {
|
|
129
|
-
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
|
|
130
|
-
}
|
|
131
|
-
function textOf(el) {
|
|
132
|
-
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
133
|
-
if (!text) return void 0;
|
|
134
|
-
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
|
|
135
|
-
}
|
|
136
|
-
//#endregion
|
|
137
51
|
//#region ../browser-session/src/replay/capture/network.ts
|
|
138
52
|
/**
|
|
139
53
|
* Capture fetch + XHR requests as session events, tagged with the active trace
|
|
@@ -167,7 +81,7 @@ function installNetworkCapture(emit, ignoreUrl) {
|
|
|
167
81
|
durationMs: Math.round(performance.now() - start)
|
|
168
82
|
},
|
|
169
83
|
traceId,
|
|
170
|
-
...error ? { attrs: { error } } :
|
|
84
|
+
...error ? { attrs: { error } } : void 0
|
|
171
85
|
});
|
|
172
86
|
};
|
|
173
87
|
const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
|
|
@@ -210,22 +124,18 @@ function requestMethod(input, init) {
|
|
|
210
124
|
//#endregion
|
|
211
125
|
//#region ../browser-session/src/replay/events.ts
|
|
212
126
|
/**
|
|
213
|
-
* Install the capture modules that turn
|
|
214
|
-
*
|
|
127
|
+
* Install the capture modules that turn console output and network requests
|
|
128
|
+
* into distilled session events.
|
|
215
129
|
*
|
|
216
|
-
* Navigation
|
|
217
|
-
*
|
|
218
|
-
*
|
|
130
|
+
* Navigation, errors and interactions are deliberately absent: the sink
|
|
131
|
+
* installs those itself (see `startBaselineCapture`), because page views, error
|
|
132
|
+
* counts and click counts have to be counted even when replay is unsampled —
|
|
133
|
+
* and a second listener here would double-count every one of them.
|
|
219
134
|
*/
|
|
220
135
|
function startEventCapture(config, sessionId) {
|
|
221
136
|
const sink = startEventSink(config, sessionId);
|
|
222
137
|
const emit = sink.emit;
|
|
223
|
-
const uninstall = [
|
|
224
|
-
installInteractionCapture(emit, config.maskAllText),
|
|
225
|
-
installConsoleCapture(emit),
|
|
226
|
-
installNetworkCapture(emit, sink.ignoreUrl),
|
|
227
|
-
installErrorCapture(emit)
|
|
228
|
-
];
|
|
138
|
+
const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl)];
|
|
229
139
|
return {
|
|
230
140
|
stop: () => {
|
|
231
141
|
for (const off of uninstall) off();
|
|
@@ -243,6 +153,9 @@ const FLUSH_INTERVAL_MS = 5e3;
|
|
|
243
153
|
const FLUSH_BYTES = 102400;
|
|
244
154
|
const CHECKOUT_EVERY_MS = 3e5;
|
|
245
155
|
const MAX_BUFFER_BYTES = 4194304;
|
|
156
|
+
function warnExhausted(sessionId) {
|
|
157
|
+
console.warn(`[maple] session replay ${sessionId} reached its maximum recorded size; recording stopped for this session (metadata and events continue)`);
|
|
158
|
+
}
|
|
246
159
|
let lastDropWarnAt = 0;
|
|
247
160
|
function warnBufferDropped(bytes) {
|
|
248
161
|
const now = Date.now();
|
|
@@ -258,6 +171,8 @@ function startRecording(config, sessionId) {
|
|
|
258
171
|
let lastTimestamp = 0;
|
|
259
172
|
let droppedChunk = false;
|
|
260
173
|
let clickCount = 0;
|
|
174
|
+
let stopped = false;
|
|
175
|
+
let exhausted = false;
|
|
261
176
|
const resetBuffer = () => {
|
|
262
177
|
parts = [];
|
|
263
178
|
bufferBytes = 0;
|
|
@@ -266,7 +181,7 @@ function startRecording(config, sessionId) {
|
|
|
266
181
|
lastTimestamp = 0;
|
|
267
182
|
};
|
|
268
183
|
const flush = async (keepalive = false) => {
|
|
269
|
-
if (parts.length === 0) return;
|
|
184
|
+
if (stopped || exhausted || parts.length === 0) return;
|
|
270
185
|
const body = `[${parts.join(",")}]`;
|
|
271
186
|
const isCheckpoint = bufferHasCheckpoint;
|
|
272
187
|
const eventCount = parts.length;
|
|
@@ -274,14 +189,19 @@ function startRecording(config, sessionId) {
|
|
|
274
189
|
const seq = nextChunkSeq();
|
|
275
190
|
resetBuffer();
|
|
276
191
|
const gzipped = await gzip(new TextEncoder().encode(body));
|
|
277
|
-
await postSessionBlob(config, {
|
|
192
|
+
if (await postSessionBlob(config, {
|
|
278
193
|
sessionId,
|
|
279
194
|
chunkSeq: seq,
|
|
280
195
|
isCheckpoint,
|
|
281
196
|
eventCount,
|
|
282
197
|
durationMs
|
|
283
|
-
}, gzipped, keepalive)
|
|
198
|
+
}, gzipped, keepalive) === "exhausted" && !exhausted) {
|
|
199
|
+
exhausted = true;
|
|
200
|
+
warnExhausted(sessionId);
|
|
201
|
+
haltCapture();
|
|
202
|
+
}
|
|
284
203
|
};
|
|
204
|
+
let haltCapture = () => {};
|
|
285
205
|
const stop = record({
|
|
286
206
|
emit: (event, isCheckpoint) => {
|
|
287
207
|
const active = markActivity();
|
|
@@ -311,25 +231,41 @@ function startRecording(config, sessionId) {
|
|
|
311
231
|
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
312
232
|
},
|
|
313
233
|
maskAllInputs: config.maskAllInputs,
|
|
314
|
-
...config.maskAllText ? { maskTextSelector: "*" } :
|
|
234
|
+
...config.maskAllText ? { maskTextSelector: "*" } : void 0,
|
|
315
235
|
checkoutEveryNms: CHECKOUT_EVERY_MS
|
|
316
236
|
});
|
|
237
|
+
let idleHandle;
|
|
317
238
|
const scheduleFlush = () => {
|
|
318
|
-
if (typeof requestIdleCallback === "function") requestIdleCallback(() =>
|
|
239
|
+
if (typeof requestIdleCallback === "function") idleHandle = requestIdleCallback(() => {
|
|
240
|
+
idleHandle = void 0;
|
|
241
|
+
flush();
|
|
242
|
+
}, { timeout: 2e3 });
|
|
319
243
|
else flush();
|
|
320
244
|
};
|
|
321
245
|
const flushTimer = setInterval(scheduleFlush, FLUSH_INTERVAL_MS);
|
|
246
|
+
let halted = false;
|
|
247
|
+
haltCapture = () => {
|
|
248
|
+
if (halted) return;
|
|
249
|
+
halted = true;
|
|
250
|
+
clearInterval(flushTimer);
|
|
251
|
+
if (idleHandle !== void 0 && typeof cancelIdleCallback === "function") {
|
|
252
|
+
cancelIdleCallback(idleHandle);
|
|
253
|
+
idleHandle = void 0;
|
|
254
|
+
}
|
|
255
|
+
resetBuffer();
|
|
256
|
+
stop?.();
|
|
257
|
+
};
|
|
322
258
|
return {
|
|
323
259
|
stop: () => {
|
|
324
|
-
|
|
325
|
-
|
|
260
|
+
stopped = true;
|
|
261
|
+
haltCapture();
|
|
326
262
|
},
|
|
327
263
|
flush,
|
|
328
264
|
getClickCount: () => clickCount
|
|
329
265
|
};
|
|
330
266
|
}
|
|
331
267
|
//#endregion
|
|
332
|
-
//#region ../browser-session/src/replay-session.ts
|
|
268
|
+
//#region ../browser-session/src/session/replay-session.ts
|
|
333
269
|
/**
|
|
334
270
|
* Start recording the current browser session. Publishes the session sink,
|
|
335
271
|
* posts an `active` metadata row, and installs visibility handlers:
|
|
@@ -345,6 +281,7 @@ function startReplaySession(options) {
|
|
|
345
281
|
const engineConfig = {
|
|
346
282
|
endpoint: options.endpoint.replace(/\/$/, ""),
|
|
347
283
|
ingestKey: options.ingestKey,
|
|
284
|
+
sdk: options.sdk,
|
|
348
285
|
maskAllInputs: options.maskAllInputs,
|
|
349
286
|
maskAllText: options.maskAllText
|
|
350
287
|
};
|
|
@@ -1,4 +1,48 @@
|
|
|
1
|
-
//#region ../browser-session/src/
|
|
1
|
+
//#region ../browser-session/src/platform/json.ts
|
|
2
|
+
/**
|
|
3
|
+
* Minimal structural guards for records read back out of browser storage.
|
|
4
|
+
*
|
|
5
|
+
* These replace what was an `effect/Schema` decoder. This package is *bundled*
|
|
6
|
+
* into `@maple-dev/browser`, whose eager chunk every visitor downloads before
|
|
7
|
+
* any sampling decision runs — and pulling Schema in to validate two flat
|
|
8
|
+
* records cost ~30 kB gzipped, most of that SDK's entire page-load budget, for
|
|
9
|
+
* shapes the callers already declare in TypeScript. Schema earns its size where
|
|
10
|
+
* the input is genuinely unknown and the errors need to be legible; here the
|
|
11
|
+
* only question is "did we write this, and is it still the shape we write",
|
|
12
|
+
* and the only answer anyone acts on is yes/no.
|
|
13
|
+
*
|
|
14
|
+
* The semantics deliberately match what the Schema decoders did, because the
|
|
15
|
+
* records in the wild were written by them:
|
|
16
|
+
*
|
|
17
|
+
* - unknown keys are dropped rather than rejected (callers rebuild an explicit
|
|
18
|
+
* object from the keys they know),
|
|
19
|
+
* - a required key that is absent or wrongly typed rejects the whole record,
|
|
20
|
+
* - an optional key must be either absent or correctly typed — `null` is not
|
|
21
|
+
* an accepted stand-in for absent, matching `Schema.optionalKey`.
|
|
22
|
+
*/
|
|
23
|
+
/** A non-null, non-array object — the only JSON shape these records take. */
|
|
24
|
+
function isJsonObject(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse a storage string into a plain object, or `undefined` if it is not
|
|
29
|
+
* valid JSON or not an object. Never throws: every caller here treats a
|
|
30
|
+
* corrupt record as a cache miss, not as an error worth surfacing.
|
|
31
|
+
*/
|
|
32
|
+
function parseJsonObject(raw) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
return isJsonObject(parsed) ? parsed : void 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A `Record<string, string>` — every value a string, as `Schema.Record` required. */
|
|
41
|
+
function isStringRecord(value) {
|
|
42
|
+
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region ../browser-session/src/identity/visitor.ts
|
|
2
46
|
/**
|
|
3
47
|
* A persistent per-browser visitor id.
|
|
4
48
|
*
|
|
@@ -147,14 +191,15 @@ function cookieDomain() {
|
|
|
147
191
|
}
|
|
148
192
|
function parseRecord(raw) {
|
|
149
193
|
if (!raw) return void 0;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
194
|
+
const value = parseJsonObject(raw);
|
|
195
|
+
if (!value) return void 0;
|
|
196
|
+
const { id, mintedAt } = value;
|
|
197
|
+
if (typeof id !== "string" || typeof mintedAt !== "number") return void 0;
|
|
198
|
+
if (Date.now() - mintedAt > MAX_AGE_MS) return void 0;
|
|
199
|
+
return {
|
|
200
|
+
id,
|
|
201
|
+
mintedAt
|
|
202
|
+
};
|
|
158
203
|
}
|
|
159
204
|
function readFromStorage() {
|
|
160
205
|
try {
|
|
@@ -259,7 +304,245 @@ function setVisitorTracking(nextEnabled) {
|
|
|
259
304
|
if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
|
|
260
305
|
}
|
|
261
306
|
//#endregion
|
|
262
|
-
//#region ../browser-session/src/
|
|
307
|
+
//#region ../browser-session/src/events/trace-id.ts
|
|
308
|
+
const ZERO_TRACE_ID = "00000000000000000000000000000000";
|
|
309
|
+
let traceIdProvider = () => void 0;
|
|
310
|
+
/** Wire the host SDK's active-trace-id lookup into event capture. */
|
|
311
|
+
function setActiveTraceIdProvider(provider) {
|
|
312
|
+
traceIdProvider = provider;
|
|
313
|
+
}
|
|
314
|
+
/** The trace id of the active span, or undefined when none is active. */
|
|
315
|
+
function activeTraceId() {
|
|
316
|
+
const id = traceIdProvider();
|
|
317
|
+
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region ../browser-session/src/capture/shared.ts
|
|
321
|
+
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
322
|
+
function safeEmit(emit, ev) {
|
|
323
|
+
try {
|
|
324
|
+
emit(ev);
|
|
325
|
+
} catch {}
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region ../browser-session/src/capture/errors.ts
|
|
329
|
+
const MAX_STACK = 4e3;
|
|
330
|
+
/** Capture uncaught errors + unhandled promise rejections as session events. */
|
|
331
|
+
function installErrorCapture(emit) {
|
|
332
|
+
const onError = (event) => {
|
|
333
|
+
safeEmit(emit, {
|
|
334
|
+
type: "error",
|
|
335
|
+
level: "error",
|
|
336
|
+
message: event.message || String(event.error ?? "Error"),
|
|
337
|
+
errorStack: truncate(event.error?.stack),
|
|
338
|
+
traceId: activeTraceId()
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
const onRejection = (event) => {
|
|
342
|
+
const reason = event.reason;
|
|
343
|
+
safeEmit(emit, {
|
|
344
|
+
type: "error",
|
|
345
|
+
level: "error",
|
|
346
|
+
message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
|
|
347
|
+
errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
|
|
348
|
+
traceId: activeTraceId()
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
window.addEventListener("error", onError);
|
|
352
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
353
|
+
return () => {
|
|
354
|
+
window.removeEventListener("error", onError);
|
|
355
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function truncate(stack) {
|
|
359
|
+
if (!stack) return void 0;
|
|
360
|
+
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region ../browser-session/src/capture/interactions.ts
|
|
364
|
+
const MAX_TEXT = 120;
|
|
365
|
+
/**
|
|
366
|
+
* Capture clicks and input events as session events. Listens in the capture
|
|
367
|
+
* phase so it sees interactions even when the host app calls
|
|
368
|
+
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
369
|
+
* element. Click target text is omitted when `maskAllText` is set.
|
|
370
|
+
*/
|
|
371
|
+
function installInteractionCapture(emit, maskAllText) {
|
|
372
|
+
const onClick = (event) => {
|
|
373
|
+
const target = event.target;
|
|
374
|
+
if (!(target instanceof Element)) return;
|
|
375
|
+
safeEmit(emit, {
|
|
376
|
+
type: "click",
|
|
377
|
+
targetSelector: selectorOf(target),
|
|
378
|
+
targetText: maskAllText ? void 0 : textOf(target)
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
const onInput = (event) => {
|
|
382
|
+
const target = event.target;
|
|
383
|
+
if (!(target instanceof Element)) return;
|
|
384
|
+
safeEmit(emit, {
|
|
385
|
+
type: "input",
|
|
386
|
+
targetSelector: selectorOf(target)
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
document.addEventListener("click", onClick, true);
|
|
390
|
+
document.addEventListener("input", onInput, true);
|
|
391
|
+
return () => {
|
|
392
|
+
document.removeEventListener("click", onClick, true);
|
|
393
|
+
document.removeEventListener("input", onInput, true);
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/** A short, human-readable selector: tag + #id + .first-class. */
|
|
397
|
+
function selectorOf(el) {
|
|
398
|
+
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
|
|
399
|
+
}
|
|
400
|
+
function textOf(el) {
|
|
401
|
+
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
402
|
+
if (!text) return void 0;
|
|
403
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
|
|
404
|
+
}
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region ../browser-session/src/capture/baseline.ts
|
|
407
|
+
/**
|
|
408
|
+
* The capture that runs on **every** page load, sampled for replay or not.
|
|
409
|
+
*
|
|
410
|
+
* Errors and clicks are the analytics substrate, for the same reason navigation
|
|
411
|
+
* is: `error_count` drives the Sessions UI "has errors" filter and `click_count`
|
|
412
|
+
* separates a real visit from a bounce, so gating them behind replay sampling
|
|
413
|
+
* makes both a sample rather than a count — and a session row that reports zero
|
|
414
|
+
* errors because nothing was listening is worse than one that reports none,
|
|
415
|
+
* because it looks complete.
|
|
416
|
+
*
|
|
417
|
+
* Console and network capture stay on the sampled replay path: they patch
|
|
418
|
+
* `console.*`, `window.fetch` and `XMLHttpRequest`, which is a cost (and a
|
|
419
|
+
* surface) worth paying only for sessions that get a recording to attach it to.
|
|
420
|
+
*
|
|
421
|
+
* Owned by the sink rather than by the replay lifecycle, which is also what
|
|
422
|
+
* keeps the listeners installed exactly once — the recorder starting and
|
|
423
|
+
* stopping across visibility changes must not re-register them.
|
|
424
|
+
*/
|
|
425
|
+
function startBaselineCapture(emit, maskAllText) {
|
|
426
|
+
if (typeof window === "undefined" || typeof document === "undefined") return () => {};
|
|
427
|
+
const uninstall = [installErrorCapture(emit), installInteractionCapture(emit, maskAllText)];
|
|
428
|
+
return () => {
|
|
429
|
+
for (const off of uninstall) off();
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region ../browser-session/src/platform/browser-globals.ts
|
|
434
|
+
const browserNavigator = () => globalThis.navigator;
|
|
435
|
+
const browserLocation = () => globalThis.window?.location;
|
|
436
|
+
const browserDocument = () => globalThis.document;
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region ../browser-session/src/platform/transport.ts
|
|
439
|
+
/**
|
|
440
|
+
* Header stamped on every request to ingest: `<sdk-name>/<version>`, e.g.
|
|
441
|
+
* `maple-browser/0.3.0`. Browsers refuse to let a page set `user-agent`, so
|
|
442
|
+
* without this a rejected request carried nothing that said which SDK build
|
|
443
|
+
* produced it. Ingest records it as `maple.sdk` on the request span. Every
|
|
444
|
+
* gateway that receives browser traffic must allow it in CORS (`apps/ingest`
|
|
445
|
+
* and the CLI's local listener do) — a header the SDK always sends and
|
|
446
|
+
* preflight refuses blocks the whole SDK, not just the header.
|
|
447
|
+
*/
|
|
448
|
+
const SDK_HINT_HEADER = "x-maple-sdk";
|
|
449
|
+
/** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
|
|
450
|
+
const sdkHint = (name, version) => `${name}/${version}`;
|
|
451
|
+
/** Auth + identity headers shared by every ingest request. */
|
|
452
|
+
function ingestHeaders(config) {
|
|
453
|
+
return {
|
|
454
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
455
|
+
[SDK_HINT_HEADER]: config.sdk
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const lastWarnAt = /* @__PURE__ */ new Map();
|
|
459
|
+
function warnDropped(what, error) {
|
|
460
|
+
const now = Date.now();
|
|
461
|
+
if (now - (lastWarnAt.get(what) ?? 0) < 3e4) return;
|
|
462
|
+
lastWarnAt.set(what, now);
|
|
463
|
+
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Largest body we will hand to a `keepalive` fetch.
|
|
467
|
+
*
|
|
468
|
+
* The Fetch spec caps the *combined* inflight keepalive body at 64 KiB, and on
|
|
469
|
+
* the way out we issue up to three of these at once (metadata row, final events
|
|
470
|
+
* batch, last replay chunk). Over the budget the browser rejects the request
|
|
471
|
+
* outright, so a normal request — which the page may or may not survive long
|
|
472
|
+
* enough to finish — is strictly the better bet than a guaranteed rejection.
|
|
473
|
+
*/
|
|
474
|
+
const MAX_KEEPALIVE_BYTES = 49152;
|
|
475
|
+
/** Whether a body of `bytes` may still ride the keepalive budget. */
|
|
476
|
+
function keepaliveFor(requested, bytes) {
|
|
477
|
+
return requested && bytes <= MAX_KEEPALIVE_BYTES;
|
|
478
|
+
}
|
|
479
|
+
/** gzip a byte buffer using the native CompressionStream (no library). */
|
|
480
|
+
async function gzip(bytes) {
|
|
481
|
+
const stream = new CompressionStream("gzip");
|
|
482
|
+
const writer = stream.writable.getWriter();
|
|
483
|
+
writer.write(bytes);
|
|
484
|
+
writer.close();
|
|
485
|
+
const buffer = await new Response(stream.readable).arrayBuffer();
|
|
486
|
+
return new Uint8Array(buffer);
|
|
487
|
+
}
|
|
488
|
+
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
489
|
+
async function postSessionMeta(config, row, keepalive = false) {
|
|
490
|
+
const body = `${JSON.stringify(row)}\n`;
|
|
491
|
+
await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers: {
|
|
494
|
+
...ingestHeaders(config),
|
|
495
|
+
"content-type": "application/x-ndjson"
|
|
496
|
+
},
|
|
497
|
+
body,
|
|
498
|
+
keepalive: keepaliveFor(keepalive, body.length)
|
|
499
|
+
}).catch((error) => {
|
|
500
|
+
warnDropped("metadata POST", error);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/** POST distilled session events (NDJSON, one row per event). Best-effort. */
|
|
504
|
+
async function postSessionEvents(config, rows, keepalive = false) {
|
|
505
|
+
if (rows.length === 0) return;
|
|
506
|
+
const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
507
|
+
await fetch(`${config.endpoint}/v1/sessionEvents`, {
|
|
508
|
+
method: "POST",
|
|
509
|
+
headers: {
|
|
510
|
+
...ingestHeaders(config),
|
|
511
|
+
"content-type": "application/x-ndjson"
|
|
512
|
+
},
|
|
513
|
+
body,
|
|
514
|
+
keepalive: keepaliveFor(keepalive, body.length)
|
|
515
|
+
}).catch((error) => {
|
|
516
|
+
warnDropped("events POST", error);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const SESSION_EXHAUSTED_STATUS = 413;
|
|
520
|
+
/** POST a gzipped rrweb event chunk. */
|
|
521
|
+
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
522
|
+
try {
|
|
523
|
+
const response = await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
|
|
524
|
+
method: "POST",
|
|
525
|
+
headers: {
|
|
526
|
+
...ingestHeaders(config),
|
|
527
|
+
"content-type": "application/octet-stream",
|
|
528
|
+
"x-maple-session-id": meta.sessionId,
|
|
529
|
+
"x-maple-chunk-seq": String(meta.chunkSeq),
|
|
530
|
+
"x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
|
|
531
|
+
"x-maple-event-count": String(meta.eventCount),
|
|
532
|
+
"x-maple-duration-ms": String(meta.durationMs)
|
|
533
|
+
},
|
|
534
|
+
body: gzipped,
|
|
535
|
+
keepalive: keepaliveFor(keepalive, gzipped.byteLength)
|
|
536
|
+
});
|
|
537
|
+
if (response.ok) return "accepted";
|
|
538
|
+
return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
|
|
539
|
+
} catch (error) {
|
|
540
|
+
warnDropped("blob PUT", error);
|
|
541
|
+
return "failed";
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region ../browser-session/src/platform/user-agent.ts
|
|
263
546
|
let memo;
|
|
264
547
|
/** Best-effort UA parse — enough to populate filterable session facets. */
|
|
265
548
|
function parseUserAgent(ua) {
|
|
@@ -279,7 +562,7 @@ function parse(ua) {
|
|
|
279
562
|
};
|
|
280
563
|
}
|
|
281
564
|
//#endregion
|
|
282
|
-
//#region ../browser-session/src/meta-row.ts
|
|
565
|
+
//#region ../browser-session/src/events/meta-row.ts
|
|
283
566
|
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
284
567
|
function formatCHDateTime(date) {
|
|
285
568
|
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
@@ -292,11 +575,10 @@ function formatCHDateTime(date) {
|
|
|
292
575
|
* exotic embedders) they fall back to empty strings.
|
|
293
576
|
*/
|
|
294
577
|
function buildSessionMetaRow(input) {
|
|
295
|
-
const
|
|
296
|
-
const userAgent = g["navigator"]?.userAgent ?? "";
|
|
578
|
+
const userAgent = browserNavigator()?.userAgent ?? "";
|
|
297
579
|
const ua = parseUserAgent(userAgent);
|
|
298
580
|
const now = /* @__PURE__ */ new Date();
|
|
299
|
-
const location =
|
|
581
|
+
const location = browserLocation();
|
|
300
582
|
const identity = input.identity;
|
|
301
583
|
const entryUrl = input.entry?.entryUrl ?? location?.href ?? "";
|
|
302
584
|
const referrer = input.entry?.referrer ?? "";
|
|
@@ -315,12 +597,13 @@ function buildSessionMetaRow(input) {
|
|
|
315
597
|
service_name: input.serviceName,
|
|
316
598
|
resource_attributes: {
|
|
317
599
|
"maple.session.recorded": input.recorded ? "true" : "false",
|
|
318
|
-
|
|
600
|
+
"maple.session.replay_format": "rrweb",
|
|
601
|
+
...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
|
|
319
602
|
...input.environment ? {
|
|
320
603
|
"deployment.environment": input.environment,
|
|
321
604
|
"deployment.environment.name": input.environment
|
|
322
|
-
} :
|
|
323
|
-
...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } :
|
|
605
|
+
} : void 0,
|
|
606
|
+
...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : void 0
|
|
324
607
|
},
|
|
325
608
|
visitor_id: input.visitorId ?? "",
|
|
326
609
|
visitor_is_new: input.visitorIsNew ? 1 : 0,
|
|
@@ -338,7 +621,7 @@ function buildSessionMetaRow(input) {
|
|
|
338
621
|
host: location?.host ?? "",
|
|
339
622
|
entry_path: pathOf(entryUrl),
|
|
340
623
|
exit_path: pathOf(input.lastUrl ?? location?.href ?? ""),
|
|
341
|
-
language:
|
|
624
|
+
language: browserNavigator()?.language ?? "",
|
|
342
625
|
last_activity_at: formatCHDateTime(now),
|
|
343
626
|
click_count: input.clickCount ?? 0,
|
|
344
627
|
page_views: input.pageViews ?? 0,
|
|
@@ -367,87 +650,20 @@ function pathOf(url) {
|
|
|
367
650
|
}
|
|
368
651
|
}
|
|
369
652
|
/** POST one session metadata row (NDJSON). Best-effort — never throws. */
|
|
370
|
-
async function postSessionMetaRow(
|
|
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) {
|
|
653
|
+
async function postSessionMetaRow(target, row, keepalive = false) {
|
|
401
654
|
const body = `${JSON.stringify(row)}\n`;
|
|
402
|
-
await fetch(`${
|
|
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`, {
|
|
655
|
+
await fetch(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
|
|
419
656
|
method: "POST",
|
|
420
657
|
headers: {
|
|
421
|
-
|
|
658
|
+
...ingestHeaders(target),
|
|
422
659
|
"content-type": "application/x-ndjson"
|
|
423
660
|
},
|
|
424
661
|
body,
|
|
425
|
-
keepalive
|
|
426
|
-
}).catch((
|
|
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
|
-
});
|
|
662
|
+
keepalive: keepaliveFor(keepalive, body.length)
|
|
663
|
+
}).catch(() => {});
|
|
448
664
|
}
|
|
449
665
|
//#endregion
|
|
450
|
-
//#region ../browser-session/src/
|
|
666
|
+
//#region ../browser-session/src/platform/approximate-size.ts
|
|
451
667
|
/** Approximate byte size of an event for flush-threshold accounting. Falls back
|
|
452
668
|
* to a fixed estimate for values that can't be serialized (e.g. cycles). */
|
|
453
669
|
function approximateSize(value) {
|
|
@@ -458,7 +674,7 @@ function approximateSize(value) {
|
|
|
458
674
|
}
|
|
459
675
|
}
|
|
460
676
|
//#endregion
|
|
461
|
-
//#region ../browser-session/src/session.ts
|
|
677
|
+
//#region ../browser-session/src/session/session.ts
|
|
462
678
|
const STORAGE_KEY = "maple.session";
|
|
463
679
|
/** Rotate the session after this much inactivity (PostHog's default). */
|
|
464
680
|
const IDLE_TIMEOUT_MS = 18e5;
|
|
@@ -471,6 +687,60 @@ const MAX_SESSION_MS = 864e5;
|
|
|
471
687
|
* — rotation correctness only needs sub-idle-timeout granularity.
|
|
472
688
|
*/
|
|
473
689
|
const ACTIVITY_TOUCH_THROTTLE_MS = 5e3;
|
|
690
|
+
/** Optional keys carrying a plain number. Absent is fine; wrongly typed is not. */
|
|
691
|
+
const OPTIONAL_NUMBERS = [
|
|
692
|
+
"metaVersion",
|
|
693
|
+
"pageViews",
|
|
694
|
+
"clickCount",
|
|
695
|
+
"errorCount"
|
|
696
|
+
];
|
|
697
|
+
/** Optional keys carrying a plain string. */
|
|
698
|
+
const OPTIONAL_STRINGS = [
|
|
699
|
+
"entryUrl",
|
|
700
|
+
"entryReferrer",
|
|
701
|
+
"lastUrl"
|
|
702
|
+
];
|
|
703
|
+
/**
|
|
704
|
+
* Validate a persisted session record. Deliberately still accepts records
|
|
705
|
+
* written by older SDKs, which have none of the optional fields — see the
|
|
706
|
+
* `SessionRecord` field comments.
|
|
707
|
+
*
|
|
708
|
+
* Returns `undefined` rather than throwing: the sole caller treats a corrupt
|
|
709
|
+
* record exactly as it treats unreadable storage.
|
|
710
|
+
*/
|
|
711
|
+
function parseSessionRecord(raw) {
|
|
712
|
+
const value = parseJsonObject(raw);
|
|
713
|
+
if (!value) return void 0;
|
|
714
|
+
const { id, startedAt, lastActivityAt, chunkSeq } = value;
|
|
715
|
+
if (typeof id !== "string" || typeof startedAt !== "number" || typeof lastActivityAt !== "number" || typeof chunkSeq !== "number") return;
|
|
716
|
+
const record = {
|
|
717
|
+
id,
|
|
718
|
+
startedAt,
|
|
719
|
+
lastActivityAt,
|
|
720
|
+
chunkSeq
|
|
721
|
+
};
|
|
722
|
+
for (const key of OPTIONAL_NUMBERS) {
|
|
723
|
+
const entry = value[key];
|
|
724
|
+
if (entry === void 0) continue;
|
|
725
|
+
if (typeof entry !== "number") return void 0;
|
|
726
|
+
record[key] = entry;
|
|
727
|
+
}
|
|
728
|
+
for (const key of OPTIONAL_STRINGS) {
|
|
729
|
+
const entry = value[key];
|
|
730
|
+
if (entry === void 0) continue;
|
|
731
|
+
if (typeof entry !== "string") return void 0;
|
|
732
|
+
record[key] = entry;
|
|
733
|
+
}
|
|
734
|
+
if (value.visitorIsNew !== void 0) {
|
|
735
|
+
if (typeof value.visitorIsNew !== "boolean") return void 0;
|
|
736
|
+
record.visitorIsNew = value.visitorIsNew;
|
|
737
|
+
}
|
|
738
|
+
if (value.utm !== void 0) {
|
|
739
|
+
if (!isStringRecord(value.utm)) return void 0;
|
|
740
|
+
record.utm = value.utm;
|
|
741
|
+
}
|
|
742
|
+
return record;
|
|
743
|
+
}
|
|
474
744
|
const UTM_KEYS = [
|
|
475
745
|
"utm_source",
|
|
476
746
|
"utm_medium",
|
|
@@ -523,9 +793,7 @@ function readRecord() {
|
|
|
523
793
|
try {
|
|
524
794
|
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
525
795
|
if (!raw) return void 0;
|
|
526
|
-
|
|
527
|
-
if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
|
|
528
|
-
return;
|
|
796
|
+
return parseSessionRecord(raw) ?? ephemeral;
|
|
529
797
|
} catch {
|
|
530
798
|
return ephemeral;
|
|
531
799
|
}
|
|
@@ -699,20 +967,9 @@ function nextMetaVersion() {
|
|
|
699
967
|
return version;
|
|
700
968
|
}
|
|
701
969
|
//#endregion
|
|
702
|
-
//#region ../browser-session/src/events-sink.ts
|
|
970
|
+
//#region ../browser-session/src/events/events-sink.ts
|
|
703
971
|
const FLUSH_INTERVAL_MS = 5e3;
|
|
704
972
|
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
973
|
/**
|
|
717
974
|
* The sink is a per-session singleton, published on `globalThis` rather than a
|
|
718
975
|
* module-level variable.
|
|
@@ -784,6 +1041,7 @@ function startEventSink(config, sessionId) {
|
|
|
784
1041
|
url
|
|
785
1042
|
});
|
|
786
1043
|
});
|
|
1044
|
+
const stopBaselineCapture = startBaselineCapture(emit, config.maskAllText);
|
|
787
1045
|
const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
|
|
788
1046
|
const sink = {
|
|
789
1047
|
sessionId,
|
|
@@ -792,6 +1050,7 @@ function startEventSink(config, sessionId) {
|
|
|
792
1050
|
stop: () => {
|
|
793
1051
|
clearInterval(flushTimer);
|
|
794
1052
|
stopNavigation();
|
|
1053
|
+
stopBaselineCapture();
|
|
795
1054
|
if (holder()[SINK_KEY]?.sink === sink) holder()[SINK_KEY] = void 0;
|
|
796
1055
|
},
|
|
797
1056
|
getPageViews: () => pageViews,
|
|
@@ -913,7 +1172,7 @@ function toRow(sessionId, ev, seq) {
|
|
|
913
1172
|
};
|
|
914
1173
|
}
|
|
915
1174
|
//#endregion
|
|
916
|
-
//#region ../browser-session/src/session
|
|
1175
|
+
//#region ../browser-session/src/session/lifecycle.ts
|
|
917
1176
|
/**
|
|
918
1177
|
* How often a visible tab re-posts its `active` row.
|
|
919
1178
|
*
|
|
@@ -1056,7 +1315,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1056
1315
|
keepalive: true
|
|
1057
1316
|
});
|
|
1058
1317
|
const onVisibilityChange = () => {
|
|
1059
|
-
const doc =
|
|
1318
|
+
const doc = browserDocument();
|
|
1060
1319
|
if (!doc) return;
|
|
1061
1320
|
if (doc.visibilityState === "hidden") {
|
|
1062
1321
|
endRun({
|
|
@@ -1095,9 +1354,24 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1095
1354
|
};
|
|
1096
1355
|
}
|
|
1097
1356
|
//#endregion
|
|
1098
|
-
//#region ../browser-session/src/sink.ts
|
|
1357
|
+
//#region ../browser-session/src/session/sink.ts
|
|
1099
1358
|
const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
|
|
1100
1359
|
const observedTraceIdsBySession = /* @__PURE__ */ new Map();
|
|
1360
|
+
/**
|
|
1361
|
+
* Ceiling on trace ids retained per session.
|
|
1362
|
+
*
|
|
1363
|
+
* A session lives up to 24h and every span feeds this, so it is otherwise
|
|
1364
|
+
* unbounded — and the whole set is serialized into the `ended` metadata row,
|
|
1365
|
+
* which is written with `keepalive` on the way out. The Fetch spec caps the
|
|
1366
|
+
* *combined* keepalive body across in-flight requests at 64 KiB, shared here
|
|
1367
|
+
* with the final events flush and the last replay chunk, so an app emitting a
|
|
1368
|
+
* span a second would silently lose its entire session row after ~30 minutes.
|
|
1369
|
+
*
|
|
1370
|
+
* Keep-first rather than keep-last: the ids are a join key for "show me this
|
|
1371
|
+
* session's traces", the UI paginates them anyway, and dropping the tail of a
|
|
1372
|
+
* long session is a smaller loss than dropping the row.
|
|
1373
|
+
*/
|
|
1374
|
+
const MAX_TRACE_IDS_PER_SESSION = 200;
|
|
1101
1375
|
/** Record a trace id seen during the session. Idempotent per id. */
|
|
1102
1376
|
function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
|
|
1103
1377
|
if (!sessionId) return;
|
|
@@ -1106,6 +1380,7 @@ function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
|
|
|
1106
1380
|
ids = /* @__PURE__ */ new Set();
|
|
1107
1381
|
observedTraceIdsBySession.set(sessionId, ids);
|
|
1108
1382
|
}
|
|
1383
|
+
if (ids.size >= MAX_TRACE_IDS_PER_SESSION && !ids.has(traceId)) return;
|
|
1109
1384
|
ids.add(traceId);
|
|
1110
1385
|
}
|
|
1111
1386
|
function getObservedTraceIds(sessionId = readSessionSink()?.sessionId) {
|
|
@@ -1146,4 +1421,4 @@ function readSessionSink() {
|
|
|
1146
1421
|
return globalThis[SESSION_SINK_KEY];
|
|
1147
1422
|
}
|
|
1148
1423
|
//#endregion
|
|
1149
|
-
export {
|
|
1424
|
+
export { setActiveTraceIdProvider as C, activeTraceId as S, setVisitorTracking as T, gzip as _, recordTraceId as a, sdkHint as b, getActiveSink as c, getSession as d, markActivity as f, SDK_HINT_HEADER as g, postSessionMetaRow as h, readSessionSink as i, queuePending as l, rotateSession as m, getObservedTraceIds as n, startSessionLifecycle as o, nextChunkSeq as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, startEventSink as u, postSessionBlob as v, configureVisitorCookie as w, safeEmit as x, postSessionMeta as y };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maple-dev/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Maple browser SDK — OpenTelemetry tracing and rrweb session replay in one package. Every span and replay event shares a session id for trace↔replay correlation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"maple",
|
|
@@ -10,22 +10,36 @@
|
|
|
10
10
|
"rum",
|
|
11
11
|
"session-replay"
|
|
12
12
|
],
|
|
13
|
+
"homepage": "https://github.com/MapleTechLabs/maple/tree/main/packages/browser#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/MapleTechLabs/maple/issues"
|
|
16
|
+
},
|
|
13
17
|
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/MapleTechLabs/maple.git",
|
|
21
|
+
"directory": "packages/browser"
|
|
22
|
+
},
|
|
14
23
|
"files": [
|
|
15
24
|
"dist",
|
|
16
25
|
"README.md"
|
|
17
26
|
],
|
|
18
27
|
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
19
29
|
"exports": {
|
|
20
30
|
".": {
|
|
21
31
|
"types": "./dist/index.d.mts",
|
|
22
32
|
"import": "./dist/index.mjs"
|
|
23
33
|
}
|
|
24
34
|
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
25
38
|
"scripts": {
|
|
26
39
|
"build": "tsdown",
|
|
27
40
|
"typecheck": "tsc --noEmit",
|
|
28
|
-
"test": "vitest run"
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"size": "bun scripts/size.ts"
|
|
29
43
|
},
|
|
30
44
|
"dependencies": {
|
|
31
45
|
"@opentelemetry/api": "^1.9.0",
|
|
@@ -40,6 +54,7 @@
|
|
|
40
54
|
},
|
|
41
55
|
"devDependencies": {
|
|
42
56
|
"@maple/browser-session": "0.1.0",
|
|
57
|
+
"jsdom": "^29.1.1",
|
|
43
58
|
"tsdown": "^0.22.14",
|
|
44
59
|
"typescript": "^6.0.3",
|
|
45
60
|
"vitest": "^4.1.9"
|