@maple-dev/browser 0.1.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/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @maple-dev/browser
2
+
3
+ Browser SDK for [Maple](https://maple.dev) — OpenTelemetry tracing **and** rrweb
4
+ session replay in a single package. Every span and every replay event is tagged
5
+ with the same `session.id`, so a trace can link straight to the replay that
6
+ produced it (and vice versa) with no clock-skew guessing.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @maple-dev/browser
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```ts
17
+ import { MapleBrowser } from "@maple-dev/browser"
18
+
19
+ MapleBrowser.init({
20
+ ingestKey: "maple_pk_...", // public ingest key
21
+ serviceName: "acme-web",
22
+ environment: "production",
23
+ replay: { enabled: true, sampleRate: 1.0 },
24
+ privacy: { maskAllInputs: true },
25
+ })
26
+ ```
27
+
28
+ That single call:
29
+
30
+ - starts OTel browser tracing, auto-instrumenting `fetch`, exporting to Maple's
31
+ ingest (`POST /v1/traces`);
32
+ - records the session with rrweb, chunking events (~5s / 100KB windows),
33
+ gzipping them with the native `CompressionStream`, and uploading to
34
+ `POST /v1/sessionReplays/blob`;
35
+ - writes session metadata at start (`active`) and on page hide (`ended`),
36
+ including the trace ids observed during the session.
37
+
38
+ ## Privacy
39
+
40
+ `maskAllInputs` (default **on**) masks every `<input>` value. Use rrweb's
41
+ attribute hooks (`data-rr-block`, `.rr-block`, `.rr-ignore`) to block elements
42
+ or subtrees from capture.
43
+
44
+ ## Notes
45
+
46
+ - Replay event blobs live in object storage; only small, queryable metadata is
47
+ indexed — playback streams blobs directly via signed URLs.
48
+ - The SDK is best-effort: network failures in telemetry never throw into your
49
+ app.
@@ -0,0 +1,67 @@
1
+ //#region src/config.d.ts
2
+ /** Public configuration for `MapleBrowser.init`. */
3
+ interface MapleBrowserConfig {
4
+ /** Public ingest key (`maple_pk_...`). */
5
+ readonly ingestKey: string;
6
+ /** Service name reported on traces and stored on replay sessions. */
7
+ readonly serviceName: string;
8
+ /** Maple ingest base URL. Defaults to `https://ingest.maple.dev`. */
9
+ readonly endpoint?: string;
10
+ /** Service version / commit SHA. */
11
+ readonly serviceVersion?: string;
12
+ /** Deployment environment, e.g. "production". */
13
+ readonly environment?: string;
14
+ /** Optional user id attached to the replay session. */
15
+ readonly userId?: string;
16
+ readonly tracing?: {
17
+ /** Default true. */readonly enabled?: boolean;
18
+ /**
19
+ * Auto-instrument `fetch()` to create network spans. Default true. Set
20
+ * false when another tracer (e.g. the Effect client SDK) already
21
+ * instruments requests — those spans feed the session via the published
22
+ * sink, and disabling this avoids redundant duplicate network spans.
23
+ */
24
+ readonly instrumentFetch?: boolean;
25
+ };
26
+ readonly replay?: {
27
+ /** Default true. */readonly enabled?: boolean; /** Fraction of sessions to record, 0–1. Default 1. */
28
+ readonly sampleRate?: number;
29
+ };
30
+ readonly privacy?: {
31
+ /** Mask all `<input>` values. Default true. */readonly maskAllInputs?: boolean;
32
+ /**
33
+ * Mask all text in the rrweb recording and omit captured click target
34
+ * text from session events. Default false.
35
+ */
36
+ readonly maskAllText?: boolean;
37
+ };
38
+ }
39
+ //#endregion
40
+ //#region src/init.d.ts
41
+ interface MapleBrowserHandle {
42
+ readonly sessionId: string;
43
+ /** Tear down tracing + replay (flushing the final chunk). */
44
+ readonly shutdown: () => Promise<void>;
45
+ }
46
+ //#endregion
47
+ //#region src/index.d.ts
48
+ /**
49
+ * Maple browser SDK. One call wires up OpenTelemetry tracing and rrweb session
50
+ * replay, both tagged with a shared session id.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { MapleBrowser } from "@maple-dev/browser"
55
+ *
56
+ * MapleBrowser.init({
57
+ * ingestKey: "maple_pk_...",
58
+ * serviceName: "acme-web",
59
+ * })
60
+ * ```
61
+ */
62
+ declare const MapleBrowser: {
63
+ init: (config: MapleBrowserConfig) => MapleBrowserHandle; /** Attach (or replace) the user id on the active session. Safe to call repeatedly. */
64
+ identify: (userId: string) => void;
65
+ };
66
+ //#endregion
67
+ export { MapleBrowser, type MapleBrowserConfig, type MapleBrowserHandle };
package/dist/index.mjs ADDED
@@ -0,0 +1,778 @@
1
+ import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
2
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
3
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
+ import { resourceFromAttributes } from "@opentelemetry/resources";
5
+ import { registerInstrumentations } from "@opentelemetry/instrumentation";
6
+ import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
7
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
8
+ import { record } from "rrweb";
9
+ import { trace } from "@opentelemetry/api";
10
+ //#region src/config.ts
11
+ const DEFAULT_ENDPOINT = "https://ingest.maple.dev";
12
+ function resolveConfig(config) {
13
+ return {
14
+ ingestKey: config.ingestKey,
15
+ serviceName: config.serviceName,
16
+ endpoint: (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
17
+ serviceVersion: config.serviceVersion,
18
+ environment: config.environment,
19
+ userId: config.userId,
20
+ tracingEnabled: config.tracing?.enabled ?? true,
21
+ tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
22
+ replayEnabled: config.replay?.enabled ?? true,
23
+ replaySampleRate: config.replay?.sampleRate ?? 1,
24
+ maskAllInputs: config.privacy?.maskAllInputs ?? true,
25
+ maskAllText: config.privacy?.maskAllText ?? false
26
+ };
27
+ }
28
+ /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
29
+ function formatCHDateTime(date) {
30
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
31
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
32
+ }
33
+ //#endregion
34
+ //#region src/session.ts
35
+ const STORAGE_KEY = "maple.session";
36
+ /** Rotate the session after this much inactivity (PostHog's default). */
37
+ const IDLE_TIMEOUT_MS = 30 * 6e4;
38
+ /** Hard cap on a single session's lifetime regardless of activity. */
39
+ const MAX_SESSION_MS = 1440 * 6e4;
40
+ /** In-memory fallback when sessionStorage is unavailable (private mode). */
41
+ let ephemeral;
42
+ function freshRecord(now) {
43
+ return {
44
+ id: crypto.randomUUID(),
45
+ startedAt: now,
46
+ lastActivityAt: now,
47
+ chunkSeq: 0
48
+ };
49
+ }
50
+ function readRecord() {
51
+ try {
52
+ const raw = window.sessionStorage.getItem(STORAGE_KEY);
53
+ if (!raw) return void 0;
54
+ const parsed = JSON.parse(raw);
55
+ if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
56
+ return;
57
+ } catch {
58
+ return ephemeral;
59
+ }
60
+ }
61
+ function writeRecord(record) {
62
+ ephemeral = record;
63
+ try {
64
+ window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
65
+ } catch {}
66
+ }
67
+ function isExpired(record, now) {
68
+ return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
69
+ }
70
+ /**
71
+ * Resolve the active session, rotating to a fresh one if the previous session
72
+ * has gone idle (or hit the lifetime cap). Touches `lastActivityAt` so calling
73
+ * it on page load keeps a live session alive. The id is the correlation key
74
+ * shared by OTel traces and replay events.
75
+ */
76
+ function getSession() {
77
+ const now = Date.now();
78
+ const existing = readRecord();
79
+ const record = existing && !isExpired(existing, now) ? {
80
+ ...existing,
81
+ lastActivityAt: now
82
+ } : freshRecord(now);
83
+ writeRecord(record);
84
+ return record;
85
+ }
86
+ /** Mark the session as active right now (called as replay chunks flush). */
87
+ function markActivity() {
88
+ const record = readRecord();
89
+ if (!record) return;
90
+ writeRecord({
91
+ ...record,
92
+ lastActivityAt: Date.now()
93
+ });
94
+ }
95
+ /**
96
+ * Take the next replay chunk sequence number for the current session. Monotonic
97
+ * across reloads (persisted on the session record), so a refresh continues the
98
+ * sequence instead of restarting at 0 and overwriting the previous load's blobs.
99
+ */
100
+ function nextChunkSeq() {
101
+ const record = readRecord() ?? freshRecord(Date.now());
102
+ const seq = record.chunkSeq;
103
+ writeRecord({
104
+ ...record,
105
+ chunkSeq: seq + 1
106
+ });
107
+ return seq;
108
+ }
109
+ /** Best-effort UA parse — enough to populate filterable session facets. */
110
+ function parseUserAgent(ua) {
111
+ return {
112
+ 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",
113
+ osName: /windows/i.test(ua) ? "Windows" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /iphone|ipad|ios/i.test(ua) ? "iOS" : /linux/i.test(ua) ? "Linux" : "Unknown",
114
+ deviceType: /mobile|iphone|android.*mobile/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
115
+ };
116
+ }
117
+ //#endregion
118
+ //#region src/session-sink.ts
119
+ const observedTraceIds = /* @__PURE__ */ new Set();
120
+ /** Record a trace id seen during the session. Idempotent per id. */
121
+ function recordTraceId(traceId) {
122
+ observedTraceIds.add(traceId);
123
+ }
124
+ function getObservedTraceIds() {
125
+ return Array.from(observedTraceIds);
126
+ }
127
+ const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
128
+ /**
129
+ * Publish the session sink on `globalThis` so other tracers in the page (e.g. the
130
+ * Effect client SDK) can attach their trace ids to this replay session without a
131
+ * direct dependency on `@maple-dev/browser`. Reads are lazy/per-span on the consumer
132
+ * side, so init ordering between the SDKs does not matter.
133
+ */
134
+ function publishSessionSink(sessionId) {
135
+ globalThis[SESSION_SINK_KEY] = {
136
+ sessionId,
137
+ recordTraceId
138
+ };
139
+ }
140
+ //#endregion
141
+ //#region src/tracing.ts
142
+ /**
143
+ * Captures every span's trace id into the session sink. Lightweight — runs
144
+ * alongside the BatchSpanProcessor, does no export of its own.
145
+ */
146
+ var TraceIdCollector = class {
147
+ onStart(span) {
148
+ recordTraceId(span.spanContext().traceId);
149
+ }
150
+ onEnd(_span) {}
151
+ forceFlush() {
152
+ return Promise.resolve();
153
+ }
154
+ shutdown() {
155
+ return Promise.resolve();
156
+ }
157
+ };
158
+ /**
159
+ * Set up browser OTel tracing exporting to Maple's ingest, tagging the resource
160
+ * with the shared `session.id`. When `tracingInstrumentFetch` is true, fetch()
161
+ * calls are auto-instrumented and their trace ids feed the session. Disable it
162
+ * when an external tracer (e.g. the Effect client SDK) already instruments
163
+ * requests — that tracer feeds the session via the published sink instead, and
164
+ * this avoids redundant duplicate network spans. Returns a shutdown function.
165
+ */
166
+ function setupTracing(config, sessionId) {
167
+ const attributes = {
168
+ [ATTR_SERVICE_NAME]: config.serviceName,
169
+ "maple.sdk.type": "browser",
170
+ "session.id": sessionId
171
+ };
172
+ if (config.serviceVersion) {
173
+ attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
174
+ attributes["deployment.commit_sha"] = config.serviceVersion;
175
+ }
176
+ if (config.environment) {
177
+ attributes["deployment.environment"] = config.environment;
178
+ attributes["deployment.environment.name"] = config.environment;
179
+ }
180
+ const exporter = new OTLPTraceExporter({
181
+ url: `${config.endpoint}/v1/traces`,
182
+ headers: { Authorization: `Bearer ${config.ingestKey}` }
183
+ });
184
+ const provider = new WebTracerProvider({
185
+ resource: resourceFromAttributes(attributes),
186
+ spanProcessors: [new TraceIdCollector(), new BatchSpanProcessor(exporter)]
187
+ });
188
+ provider.register();
189
+ if (config.tracingInstrumentFetch) registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] });
190
+ return () => provider.shutdown();
191
+ }
192
+ function escapeRegExp(value) {
193
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
194
+ }
195
+ //#endregion
196
+ //#region src/replay/transport.ts
197
+ let lastWarnAt = 0;
198
+ function warnDropped(what, error) {
199
+ const now = Date.now();
200
+ if (now - lastWarnAt < 3e4) return;
201
+ lastWarnAt = now;
202
+ console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
203
+ }
204
+ /** gzip a byte buffer using the native CompressionStream (no library). */
205
+ async function gzip(bytes) {
206
+ const stream = new CompressionStream("gzip");
207
+ const writer = stream.writable.getWriter();
208
+ writer.write(bytes);
209
+ writer.close();
210
+ const buffer = await new Response(stream.readable).arrayBuffer();
211
+ return new Uint8Array(buffer);
212
+ }
213
+ /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
214
+ async function postSessionMeta(config, row, keepalive = false) {
215
+ const body = `${JSON.stringify(row)}\n`;
216
+ await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
217
+ method: "POST",
218
+ headers: {
219
+ Authorization: `Bearer ${config.ingestKey}`,
220
+ "content-type": "application/x-ndjson"
221
+ },
222
+ body,
223
+ keepalive
224
+ }).catch((error) => {
225
+ warnDropped("metadata POST", error);
226
+ });
227
+ }
228
+ /** POST distilled session events (NDJSON, one row per event). Best-effort. */
229
+ async function postSessionEvents(config, rows, keepalive = false) {
230
+ if (rows.length === 0) return;
231
+ const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
232
+ await fetch(`${config.endpoint}/v1/sessionEvents`, {
233
+ method: "POST",
234
+ headers: {
235
+ Authorization: `Bearer ${config.ingestKey}`,
236
+ "content-type": "application/x-ndjson"
237
+ },
238
+ body,
239
+ keepalive
240
+ }).catch((error) => {
241
+ warnDropped("events POST", error);
242
+ });
243
+ }
244
+ /** PUT a gzipped rrweb event chunk. */
245
+ async function postSessionBlob(config, meta, gzipped, keepalive = false) {
246
+ await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
247
+ method: "POST",
248
+ headers: {
249
+ Authorization: `Bearer ${config.ingestKey}`,
250
+ "content-type": "application/octet-stream",
251
+ "x-maple-session-id": meta.sessionId,
252
+ "x-maple-chunk-seq": String(meta.chunkSeq),
253
+ "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
254
+ "x-maple-event-count": String(meta.eventCount),
255
+ "x-maple-duration-ms": String(meta.durationMs)
256
+ },
257
+ body: gzipped,
258
+ keepalive
259
+ }).catch((error) => {
260
+ warnDropped("blob PUT", error);
261
+ });
262
+ }
263
+ //#endregion
264
+ //#region src/replay/util.ts
265
+ /** Approximate byte size of an event for flush-threshold accounting. Falls back
266
+ * to a fixed estimate for values that can't be serialized (e.g. cycles). */
267
+ function approximateSize(value) {
268
+ try {
269
+ return JSON.stringify(value).length;
270
+ } catch {
271
+ return 256;
272
+ }
273
+ }
274
+ //#endregion
275
+ //#region src/replay/record.ts
276
+ const FULL_SNAPSHOT = 2;
277
+ const INCREMENTAL = 3;
278
+ const SOURCE_MOUSE_INTERACTION = 2;
279
+ const MOUSE_CLICK = 2;
280
+ const FLUSH_INTERVAL_MS$1 = 5e3;
281
+ const FLUSH_BYTES$1 = 100 * 1024;
282
+ const CHECKOUT_EVERY_MS = 3e4;
283
+ function startRecording(config, sessionId) {
284
+ let buffer = [];
285
+ let bufferBytes = 0;
286
+ let bufferHasCheckpoint = false;
287
+ let clickCount = 0;
288
+ const flush = async (keepalive = false) => {
289
+ if (buffer.length === 0) return;
290
+ markActivity();
291
+ const events = buffer;
292
+ const isCheckpoint = bufferHasCheckpoint;
293
+ const seq = nextChunkSeq();
294
+ const first = events[0].timestamp;
295
+ const last = events[events.length - 1].timestamp;
296
+ buffer = [];
297
+ bufferBytes = 0;
298
+ bufferHasCheckpoint = false;
299
+ const gzipped = await gzip(new TextEncoder().encode(JSON.stringify(events)));
300
+ await postSessionBlob(config, {
301
+ sessionId,
302
+ chunkSeq: seq,
303
+ isCheckpoint,
304
+ eventCount: events.length,
305
+ durationMs: Math.max(0, last - first)
306
+ }, gzipped, keepalive);
307
+ };
308
+ const stop = record({
309
+ emit: (event, isCheckpoint) => {
310
+ const e = event;
311
+ if (isCheckpoint === true || e.type === FULL_SNAPSHOT) bufferHasCheckpoint = true;
312
+ if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
313
+ buffer.push(e);
314
+ bufferBytes += approximateSize(e);
315
+ if (bufferBytes >= FLUSH_BYTES$1) flush();
316
+ },
317
+ maskAllInputs: config.maskAllInputs,
318
+ ...config.maskAllText ? { maskTextSelector: "*" } : {},
319
+ checkoutEveryNms: CHECKOUT_EVERY_MS
320
+ });
321
+ const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS$1);
322
+ return {
323
+ stop: () => {
324
+ clearInterval(flushTimer);
325
+ stop?.();
326
+ },
327
+ flush,
328
+ getClickCount: () => clickCount
329
+ };
330
+ }
331
+ //#endregion
332
+ //#region src/replay/capture/shared.ts
333
+ /** Emit best-effort: capture must never throw into the host app's call site. */
334
+ function safeEmit(emit, ev) {
335
+ try {
336
+ emit(ev);
337
+ } catch {}
338
+ }
339
+ //#endregion
340
+ //#region src/replay/capture/console.ts
341
+ const LEVELS = [
342
+ "log",
343
+ "info",
344
+ "warn",
345
+ "error",
346
+ "debug"
347
+ ];
348
+ const MAX_MESSAGE = 2e3;
349
+ /**
350
+ * Capture `console.*` calls as session events. Wraps each method, emits a
351
+ * distilled record, then forwards to the original so the host app's console
352
+ * behaves normally. Never throws into the call site.
353
+ */
354
+ function installConsoleCapture(emit) {
355
+ const original = {};
356
+ for (const level of LEVELS) {
357
+ const orig = console[level];
358
+ original[level] = orig;
359
+ console[level] = (...args) => {
360
+ safeEmit(emit, {
361
+ type: "console",
362
+ level,
363
+ message: formatArgs(args)
364
+ });
365
+ orig.apply(console, args);
366
+ };
367
+ }
368
+ return () => {
369
+ for (const level of LEVELS) {
370
+ const orig = original[level];
371
+ if (orig) console[level] = orig;
372
+ }
373
+ };
374
+ }
375
+ function formatArgs(args) {
376
+ const text = args.map((a) => {
377
+ if (typeof a === "string") return a;
378
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
379
+ try {
380
+ return JSON.stringify(a);
381
+ } catch {
382
+ return String(a);
383
+ }
384
+ }).join(" ");
385
+ return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
386
+ }
387
+ //#endregion
388
+ //#region src/replay/capture/network.ts
389
+ /**
390
+ * Capture fetch + XHR requests as session events, tagged with the active trace
391
+ * id so each request links to its backend trace. `ignoreUrl` skips Maple's own
392
+ * ingest endpoints (otherwise capturing the session-events POST would loop).
393
+ */
394
+ function installNetworkCapture(emit, ignoreUrl) {
395
+ const origFetch = typeof window !== "undefined" ? window.fetch : void 0;
396
+ if (origFetch) window.fetch = async (input, init) => {
397
+ const url = requestUrl(input);
398
+ const method = requestMethod(input, init);
399
+ const traceId = activeTraceId();
400
+ const start = performance.now();
401
+ try {
402
+ const res = await origFetch(input, init);
403
+ record(url, method, res.status, start, traceId);
404
+ return res;
405
+ } catch (error) {
406
+ record(url, method, 0, start, traceId, String(error));
407
+ throw error;
408
+ }
409
+ };
410
+ const record = (url, method, status, start, traceId, error) => {
411
+ if (ignoreUrl(url)) return;
412
+ safeEmit(emit, {
413
+ type: "network",
414
+ net: {
415
+ method,
416
+ url,
417
+ status,
418
+ durationMs: Math.round(performance.now() - start)
419
+ },
420
+ traceId,
421
+ ...error ? { attrs: { error } } : {}
422
+ });
423
+ };
424
+ const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
425
+ const origOpen = XHR?.prototype.open;
426
+ const origSend = XHR?.prototype.send;
427
+ if (XHR && origOpen && origSend) {
428
+ XHR.prototype.open = function(method, url, ...rest) {
429
+ this.__mapleMethod = String(method).toUpperCase();
430
+ this.__mapleUrl = typeof url === "string" ? url : url.href;
431
+ return origOpen.apply(this, [
432
+ method,
433
+ url,
434
+ ...rest
435
+ ]);
436
+ };
437
+ XHR.prototype.send = function(...args) {
438
+ const meta = this;
439
+ const start = performance.now();
440
+ const traceId = activeTraceId();
441
+ this.addEventListener("loadend", () => {
442
+ record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
443
+ });
444
+ return origSend.apply(this, args);
445
+ };
446
+ }
447
+ return () => {
448
+ if (origFetch) window.fetch = origFetch;
449
+ if (XHR && origOpen) XHR.prototype.open = origOpen;
450
+ if (XHR && origSend) XHR.prototype.send = origSend;
451
+ };
452
+ }
453
+ function requestUrl(input) {
454
+ if (typeof input === "string") return input;
455
+ if (input instanceof URL) return input.href;
456
+ return input.url;
457
+ }
458
+ function requestMethod(input, init) {
459
+ return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
460
+ }
461
+ //#endregion
462
+ //#region src/replay/capture/errors.ts
463
+ const MAX_STACK = 4e3;
464
+ /** Capture uncaught errors + unhandled promise rejections as session events. */
465
+ function installErrorCapture(emit) {
466
+ const onError = (event) => {
467
+ safeEmit(emit, {
468
+ type: "error",
469
+ level: "error",
470
+ message: event.message || String(event.error ?? "Error"),
471
+ errorStack: truncate(event.error?.stack),
472
+ traceId: activeTraceId()
473
+ });
474
+ };
475
+ const onRejection = (event) => {
476
+ const reason = event.reason;
477
+ safeEmit(emit, {
478
+ type: "error",
479
+ level: "error",
480
+ message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
481
+ errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
482
+ traceId: activeTraceId()
483
+ });
484
+ };
485
+ window.addEventListener("error", onError);
486
+ window.addEventListener("unhandledrejection", onRejection);
487
+ return () => {
488
+ window.removeEventListener("error", onError);
489
+ window.removeEventListener("unhandledrejection", onRejection);
490
+ };
491
+ }
492
+ function truncate(stack) {
493
+ if (!stack) return void 0;
494
+ return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
495
+ }
496
+ //#endregion
497
+ //#region src/replay/capture/navigation.ts
498
+ /**
499
+ * Capture page views as session events: the initial load plus every SPA
500
+ * navigation (history pushState/replaceState, popstate, hashchange).
501
+ */
502
+ function installNavigationCapture(emit) {
503
+ let lastUrl = "";
504
+ const emitNav = () => {
505
+ const url = location.href;
506
+ if (url === lastUrl) return;
507
+ lastUrl = url;
508
+ safeEmit(emit, {
509
+ type: "navigation",
510
+ url
511
+ });
512
+ };
513
+ emitNav();
514
+ const origPush = history.pushState;
515
+ const origReplace = history.replaceState;
516
+ history.pushState = function(...args) {
517
+ const result = origPush.apply(this, args);
518
+ emitNav();
519
+ return result;
520
+ };
521
+ history.replaceState = function(...args) {
522
+ const result = origReplace.apply(this, args);
523
+ emitNav();
524
+ return result;
525
+ };
526
+ window.addEventListener("popstate", emitNav);
527
+ window.addEventListener("hashchange", emitNav);
528
+ return () => {
529
+ history.pushState = origPush;
530
+ history.replaceState = origReplace;
531
+ window.removeEventListener("popstate", emitNav);
532
+ window.removeEventListener("hashchange", emitNav);
533
+ };
534
+ }
535
+ //#endregion
536
+ //#region src/replay/capture/interactions.ts
537
+ const MAX_TEXT = 120;
538
+ /**
539
+ * Capture clicks and input events as session events. Listens in the capture
540
+ * phase so it sees interactions even when the host app calls
541
+ * `stopPropagation()`. Input *values* are never recorded; only the target
542
+ * element. Click target text is omitted when `maskAllText` is set.
543
+ */
544
+ function installInteractionCapture(emit, maskAllText) {
545
+ const onClick = (event) => {
546
+ const target = event.target;
547
+ if (!(target instanceof Element)) return;
548
+ safeEmit(emit, {
549
+ type: "click",
550
+ targetSelector: selectorOf(target),
551
+ targetText: maskAllText ? void 0 : textOf(target)
552
+ });
553
+ };
554
+ const onInput = (event) => {
555
+ const target = event.target;
556
+ if (!(target instanceof Element)) return;
557
+ safeEmit(emit, {
558
+ type: "input",
559
+ targetSelector: selectorOf(target)
560
+ });
561
+ };
562
+ document.addEventListener("click", onClick, true);
563
+ document.addEventListener("input", onInput, true);
564
+ return () => {
565
+ document.removeEventListener("click", onClick, true);
566
+ document.removeEventListener("input", onInput, true);
567
+ };
568
+ }
569
+ /** A short, human-readable selector: tag + #id + .first-class. */
570
+ function selectorOf(el) {
571
+ return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
572
+ }
573
+ function textOf(el) {
574
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
575
+ if (!text) return void 0;
576
+ return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
577
+ }
578
+ //#endregion
579
+ //#region src/replay/events.ts
580
+ const FLUSH_INTERVAL_MS = 5e3;
581
+ const FLUSH_BYTES = 64 * 1024;
582
+ const ZERO_TRACE_ID = "00000000000000000000000000000000";
583
+ /** The OTel trace id of the active span, or undefined when none is active. */
584
+ function activeTraceId() {
585
+ const id = trace.getActiveSpan()?.spanContext().traceId;
586
+ return id && id !== ZERO_TRACE_ID ? id : void 0;
587
+ }
588
+ /**
589
+ * Capture distilled session events (console, network, errors, navigation,
590
+ * interactions) and ship them to the ingest gateway as NDJSON rows. Best-effort
591
+ * and decoupled from the rrweb recorder — runs on its own flush loop.
592
+ */
593
+ function startEventCapture(config, sessionId) {
594
+ let buffer = [];
595
+ let bufferBytes = 0;
596
+ let seq = 0;
597
+ const emit = (ev) => {
598
+ buffer.push({
599
+ ev,
600
+ seq: seq++
601
+ });
602
+ bufferBytes += approximateSize(ev);
603
+ if (bufferBytes >= FLUSH_BYTES) flush();
604
+ };
605
+ const flush = async (keepalive = false) => {
606
+ if (buffer.length === 0) return;
607
+ markActivity();
608
+ const batch = buffer;
609
+ buffer = [];
610
+ bufferBytes = 0;
611
+ await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
612
+ };
613
+ const ignoreUrl = (url) => url.startsWith(`${config.endpoint}/v1/`);
614
+ const uninstall = [
615
+ installNavigationCapture(emit),
616
+ installInteractionCapture(emit, config.maskAllText),
617
+ installConsoleCapture(emit),
618
+ installNetworkCapture(emit, ignoreUrl),
619
+ installErrorCapture(emit)
620
+ ];
621
+ const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
622
+ return {
623
+ stop: () => {
624
+ clearInterval(flushTimer);
625
+ for (const off of uninstall) off();
626
+ },
627
+ flush
628
+ };
629
+ }
630
+ /** Map an internal event to the snake_case ingest row (org_id is added server-side). */
631
+ function toRow(sessionId, ev, seq) {
632
+ return {
633
+ session_id: sessionId,
634
+ timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
635
+ seq,
636
+ type: ev.type,
637
+ url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
638
+ trace_id: ev.traceId ?? activeTraceId() ?? "",
639
+ level: ev.level ?? "",
640
+ message: ev.message ?? "",
641
+ target_selector: ev.targetSelector ?? "",
642
+ target_text: ev.targetText ?? "",
643
+ net_method: ev.net?.method ?? "",
644
+ net_url: ev.net?.url ?? "",
645
+ net_status: ev.net?.status ?? 0,
646
+ net_duration_ms: ev.net?.durationMs ?? 0,
647
+ error_stack: ev.errorStack ?? "",
648
+ attributes: ev.attrs ?? {}
649
+ };
650
+ }
651
+ //#endregion
652
+ //#region src/init.ts
653
+ let active;
654
+ let activeConfig;
655
+ /**
656
+ * Initialize Maple browser telemetry: OTel tracing + (sampled) rrweb session
657
+ * replay, both tagged with one shared session id so a trace can link to its
658
+ * replay and vice versa. Idempotent — repeated calls return the live handle.
659
+ */
660
+ function init(rawConfig) {
661
+ if (active) return active;
662
+ if (typeof window === "undefined") return {
663
+ sessionId: "",
664
+ shutdown: () => Promise.resolve()
665
+ };
666
+ const config = resolveConfig(rawConfig);
667
+ activeConfig = config;
668
+ const session = getSession();
669
+ const sessionId = session.id;
670
+ const startedAt = new Date(session.startedAt);
671
+ publishSessionSink(sessionId);
672
+ const shutdownTracing = config.tracingEnabled ? setupTracing(config, sessionId) : void 0;
673
+ const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
674
+ let recorder;
675
+ let events;
676
+ if (recordReplay) {
677
+ recorder = startRecording(config, sessionId);
678
+ events = startEventCapture(config, sessionId);
679
+ postSessionMeta(config, sessionMetaRow(config, sessionId, startedAt, 1, "active", null));
680
+ installLifecycleHandlers(config, sessionId, startedAt, recorder, events);
681
+ }
682
+ const handle = {
683
+ sessionId,
684
+ shutdown: async () => {
685
+ if (recorder) await recorder.flush(true);
686
+ if (events) await events.flush(true);
687
+ recorder?.stop();
688
+ events?.stop();
689
+ await shutdownTracing?.();
690
+ active = void 0;
691
+ activeConfig = void 0;
692
+ }
693
+ };
694
+ active = handle;
695
+ return handle;
696
+ }
697
+ /**
698
+ * Attach (or replace) the user id on the active session. Idempotent and safe to
699
+ * call on every render. The session's authoritative row is the `Version=2`
700
+ * "ended" row posted on unload, which reads `config.userId` at that moment — so
701
+ * an id set here before the session ends is what the session is tagged with.
702
+ * We deliberately do not re-post the active row (the SDK already writes a fresh
703
+ * `Version=1` row on every reload; a second write here would collide under
704
+ * `argMax(field, Version)`).
705
+ */
706
+ function identify(userId) {
707
+ if (typeof window === "undefined") return;
708
+ if (!activeConfig) return;
709
+ if (!userId) return;
710
+ if (activeConfig.userId === userId) return;
711
+ activeConfig.userId = userId;
712
+ }
713
+ function installLifecycleHandlers(config, sessionId, startedAt, recorder, events) {
714
+ let finalized = false;
715
+ const finalize = () => {
716
+ if (finalized) return;
717
+ finalized = true;
718
+ recorder.flush(true);
719
+ events.flush(true);
720
+ postSessionMeta(config, sessionMetaRow(config, sessionId, startedAt, 2, "ended", recorder.getClickCount()), true);
721
+ recorder.stop();
722
+ events.stop();
723
+ };
724
+ document.addEventListener("visibilitychange", () => {
725
+ if (document.visibilityState === "hidden") finalize();
726
+ });
727
+ window.addEventListener("pagehide", finalize);
728
+ }
729
+ function sessionMetaRow(config, sessionId, startedAt, version, status, clickCount) {
730
+ const ua = parseUserAgent(navigator.userAgent);
731
+ const now = /* @__PURE__ */ new Date();
732
+ const row = {
733
+ session_id: sessionId,
734
+ start_time: formatCHDateTime(startedAt),
735
+ status,
736
+ version,
737
+ user_id: config.userId ?? "",
738
+ url_initial: window.location.href,
739
+ user_agent: navigator.userAgent,
740
+ browser_name: ua.browserName,
741
+ os_name: ua.osName,
742
+ device_type: ua.deviceType,
743
+ service_name: config.serviceName,
744
+ resource_attributes: config.environment ? {
745
+ "deployment.environment": config.environment,
746
+ "deployment.environment.name": config.environment
747
+ } : {}
748
+ };
749
+ if (status === "ended") {
750
+ row.end_time = formatCHDateTime(now);
751
+ row.duration_ms = Math.max(0, now.getTime() - startedAt.getTime());
752
+ row.click_count = clickCount ?? 0;
753
+ row.trace_ids = getObservedTraceIds();
754
+ }
755
+ return row;
756
+ }
757
+ //#endregion
758
+ //#region src/index.ts
759
+ /**
760
+ * Maple browser SDK. One call wires up OpenTelemetry tracing and rrweb session
761
+ * replay, both tagged with a shared session id.
762
+ *
763
+ * @example
764
+ * ```ts
765
+ * import { MapleBrowser } from "@maple-dev/browser"
766
+ *
767
+ * MapleBrowser.init({
768
+ * ingestKey: "maple_pk_...",
769
+ * serviceName: "acme-web",
770
+ * })
771
+ * ```
772
+ */
773
+ const MapleBrowser = {
774
+ init,
775
+ identify
776
+ };
777
+ //#endregion
778
+ export { MapleBrowser };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@maple-dev/browser",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.mts",
14
+ "import": "./dist/index.mjs"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsdown",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest run"
21
+ },
22
+ "dependencies": {
23
+ "@opentelemetry/api": "^1.9.0",
24
+ "@opentelemetry/exporter-trace-otlp-http": "^0.205.0",
25
+ "@opentelemetry/instrumentation": "^0.205.0",
26
+ "@opentelemetry/instrumentation-fetch": "^0.205.0",
27
+ "@opentelemetry/resources": "^2.0.0",
28
+ "@opentelemetry/sdk-trace-base": "^2.0.0",
29
+ "@opentelemetry/sdk-trace-web": "^2.0.0",
30
+ "@opentelemetry/semantic-conventions": "^1.36.0",
31
+ "rrweb": "^2.0.0-alpha.18"
32
+ },
33
+ "devDependencies": {
34
+ "tsdown": "^0.21.7",
35
+ "typescript": "^6.0.2",
36
+ "vitest": "^4.1.2"
37
+ },
38
+ "keywords": [
39
+ "maple",
40
+ "opentelemetry",
41
+ "session-replay",
42
+ "rrweb",
43
+ "observability",
44
+ "rum"
45
+ ]
46
+ }