@maple-dev/browser 0.1.0 → 0.3.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 CHANGED
@@ -17,11 +17,11 @@ npm install @maple-dev/browser
17
17
  import { MapleBrowser } from "@maple-dev/browser"
18
18
 
19
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 },
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
25
  })
26
26
  ```
27
27
 
@@ -35,6 +35,19 @@ That single call:
35
35
  - writes session metadata at start (`active`) and on page hide (`ended`),
36
36
  including the trace ids observed during the session.
37
37
 
38
+ ## Identifying users
39
+
40
+ Pass `userId` to `init()` when you already know the signed-in user, or call
41
+ `MapleBrowser.identify(user.id)` later. The id is attached to future session
42
+ metadata rows and stamped as `user.id` on future browser-created spans.
43
+
44
+ ```ts
45
+ MapleBrowser.identify(user.id)
46
+
47
+ // after sign-out
48
+ MapleBrowser.identify(null)
49
+ ```
50
+
38
51
  ## Privacy
39
52
 
40
53
  `maskAllInputs` (default **on**) masks every `<input>` value. Use rrweb's
package/dist/index.d.mts CHANGED
@@ -7,12 +7,17 @@ interface MapleBrowserConfig {
7
7
  readonly serviceName: string;
8
8
  /** Maple ingest base URL. Defaults to `https://ingest.maple.dev`. */
9
9
  readonly endpoint?: string;
10
+ /**
11
+ * Logical group this service belongs to, emitted as the OTel
12
+ * `service.namespace` resource attribute on traces. Optional.
13
+ */
14
+ readonly serviceNamespace?: string;
10
15
  /** Service version / commit SHA. */
11
16
  readonly serviceVersion?: string;
12
17
  /** Deployment environment, e.g. "production". */
13
18
  readonly environment?: string;
14
- /** Optional user id attached to the replay session. */
15
- readonly userId?: string;
19
+ /** Optional user id attached to replay sessions and future browser spans. */
20
+ readonly userId?: string | null | undefined;
16
21
  readonly tracing?: {
17
22
  /** Default true. */readonly enabled?: boolean;
18
23
  /**
@@ -60,8 +65,8 @@ interface MapleBrowserHandle {
60
65
  * ```
61
66
  */
62
67
  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;
68
+ init: (config: MapleBrowserConfig) => MapleBrowserHandle; /** Attach, replace, or clear the user id on the active session. Safe to call repeatedly. */
69
+ identify: (userId?: string | null) => void;
65
70
  };
66
71
  //#endregion
67
72
  export { MapleBrowser, type MapleBrowserConfig, type MapleBrowserHandle };
package/dist/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { record } from "rrweb";
1
3
  import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
2
4
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
3
5
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
@@ -5,33 +7,7 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
5
7
  import { registerInstrumentations } from "@opentelemetry/instrumentation";
6
8
  import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
7
9
  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
10
+ //#region ../browser-session/src/session.ts
35
11
  const STORAGE_KEY = "maple.session";
36
12
  /** Rotate the session after this much inactivity (PostHog's default). */
37
13
  const IDLE_TIMEOUT_MS = 30 * 6e4;
@@ -44,7 +20,8 @@ function freshRecord(now) {
44
20
  id: crypto.randomUUID(),
45
21
  startedAt: now,
46
22
  lastActivityAt: now,
47
- chunkSeq: 0
23
+ chunkSeq: 0,
24
+ metaVersion: 0
48
25
  };
49
26
  }
50
27
  function readRecord() {
@@ -106,16 +83,26 @@ function nextChunkSeq() {
106
83
  });
107
84
  return seq;
108
85
  }
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
- };
86
+ /**
87
+ * Take the next session-metadata row version for the current session.
88
+ * Monotonic per session across reloads, hide/resume cycles, and writers (both
89
+ * SDKs share the persisted counter), so `argMax(field, Version)` on the
90
+ * backend always resolves to the most recently posted row. Records written by
91
+ * older SDKs (no `metaVersion`) already posted versions 1 and 2, so the
92
+ * counter resumes at 3 for them; a fresh session starts at 1.
93
+ */
94
+ function nextMetaVersion() {
95
+ const record = readRecord() ?? freshRecord(Date.now());
96
+ const version = (record.metaVersion ?? 2) + 1;
97
+ writeRecord({
98
+ ...record,
99
+ metaVersion: version
100
+ });
101
+ return version;
116
102
  }
117
103
  //#endregion
118
- //#region src/session-sink.ts
104
+ //#region ../browser-session/src/sink.ts
105
+ const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
119
106
  const observedTraceIds = /* @__PURE__ */ new Set();
120
107
  /** Record a trace id seen during the session. Idempotent per id. */
121
108
  function recordTraceId(traceId) {
@@ -124,12 +111,11 @@ function recordTraceId(traceId) {
124
111
  function getObservedTraceIds() {
125
112
  return Array.from(observedTraceIds);
126
113
  }
127
- const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
128
114
  /**
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.
115
+ * Publish the session sink on `globalThis` so other tracers in the page can
116
+ * attach their trace ids to the active replay session without a direct
117
+ * dependency on the publishing SDK. Reads are lazy/per-span on the consumer
118
+ * side, so init ordering between SDKs does not matter.
133
119
  */
134
120
  function publishSessionSink(sessionId) {
135
121
  globalThis[SESSION_SINK_KEY] = {
@@ -138,62 +124,63 @@ function publishSessionSink(sessionId) {
138
124
  };
139
125
  }
140
126
  //#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
- };
127
+ //#region ../browser-session/src/user-agent.ts
128
+ /** Best-effort UA parse — enough to populate filterable session facets. */
129
+ function parseUserAgent(ua) {
130
+ return {
131
+ 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",
132
+ 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",
133
+ deviceType: /mobile|iphone|android.*mobile/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
134
+ };
135
+ }
136
+ //#endregion
137
+ //#region ../browser-session/src/meta-row.ts
138
+ /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
139
+ function formatCHDateTime(date) {
140
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
141
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
142
+ }
158
143
  /**
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.
144
+ * Build one `/v1/sessionReplays/meta` NDJSON row. Shared by `@maple-dev/browser`
145
+ * and the Effect client SDK so a session looks identical no matter which SDK
146
+ * posted it. UA/URL facets come from the live browser globals; absent (tests,
147
+ * exotic embedders) they fall back to empty strings.
165
148
  */
166
- function setupTracing(config, sessionId) {
167
- const attributes = {
168
- [ATTR_SERVICE_NAME]: config.serviceName,
169
- "maple.sdk.type": "browser",
170
- "session.id": sessionId
149
+ function buildSessionMetaRow(input) {
150
+ const g = globalThis;
151
+ const userAgent = g["navigator"]?.userAgent ?? "";
152
+ const ua = parseUserAgent(userAgent);
153
+ const now = /* @__PURE__ */ new Date();
154
+ const row = {
155
+ session_id: input.sessionId,
156
+ start_time: formatCHDateTime(input.startedAt),
157
+ status: input.status,
158
+ version: input.version,
159
+ user_id: input.userId ?? "",
160
+ url_initial: g["window"]?.location?.href ?? "",
161
+ user_agent: userAgent,
162
+ browser_name: ua.browserName,
163
+ os_name: ua.osName,
164
+ device_type: ua.deviceType,
165
+ service_name: input.serviceName,
166
+ resource_attributes: {
167
+ ...input.environment ? {
168
+ "deployment.environment": input.environment,
169
+ "deployment.environment.name": input.environment
170
+ } : {},
171
+ ...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : {}
172
+ }
171
173
  };
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;
174
+ if (input.status === "ended") {
175
+ row.end_time = formatCHDateTime(now);
176
+ row.duration_ms = Math.max(0, now.getTime() - input.startedAt.getTime());
177
+ row.click_count = input.clickCount ?? 0;
178
+ row.trace_ids = input.traceIds ? Array.from(input.traceIds) : [];
179
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, "\\$&");
180
+ return row;
194
181
  }
195
182
  //#endregion
196
- //#region src/replay/transport.ts
183
+ //#region ../browser-session/src/replay/transport.ts
197
184
  let lastWarnAt = 0;
198
185
  function warnDropped(what, error) {
199
186
  const now = Date.now();
@@ -261,75 +248,7 @@ async function postSessionBlob(config, meta, gzipped, keepalive = false) {
261
248
  });
262
249
  }
263
250
  //#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
251
+ //#region ../browser-session/src/replay/capture/shared.ts
333
252
  /** Emit best-effort: capture must never throw into the host app's call site. */
334
253
  function safeEmit(emit, ev) {
335
254
  try {
@@ -337,7 +256,7 @@ function safeEmit(emit, ev) {
337
256
  } catch {}
338
257
  }
339
258
  //#endregion
340
- //#region src/replay/capture/console.ts
259
+ //#region ../browser-session/src/replay/capture/console.ts
341
260
  const LEVELS = [
342
261
  "log",
343
262
  "info",
@@ -385,7 +304,7 @@ function formatArgs(args) {
385
304
  return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
386
305
  }
387
306
  //#endregion
388
- //#region src/replay/capture/network.ts
307
+ //#region ../browser-session/src/replay/capture/network.ts
389
308
  /**
390
309
  * Capture fetch + XHR requests as session events, tagged with the active trace
391
310
  * id so each request links to its backend trace. `ignoreUrl` skips Maple's own
@@ -459,7 +378,7 @@ function requestMethod(input, init) {
459
378
  return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
460
379
  }
461
380
  //#endregion
462
- //#region src/replay/capture/errors.ts
381
+ //#region ../browser-session/src/replay/capture/errors.ts
463
382
  const MAX_STACK = 4e3;
464
383
  /** Capture uncaught errors + unhandled promise rejections as session events. */
465
384
  function installErrorCapture(emit) {
@@ -494,7 +413,7 @@ function truncate(stack) {
494
413
  return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
495
414
  }
496
415
  //#endregion
497
- //#region src/replay/capture/navigation.ts
416
+ //#region ../browser-session/src/replay/capture/navigation.ts
498
417
  /**
499
418
  * Capture page views as session events: the initial load plus every SPA
500
419
  * navigation (history pushState/replaceState, popstate, hashchange).
@@ -533,7 +452,7 @@ function installNavigationCapture(emit) {
533
452
  };
534
453
  }
535
454
  //#endregion
536
- //#region src/replay/capture/interactions.ts
455
+ //#region ../browser-session/src/replay/capture/interactions.ts
537
456
  const MAX_TEXT = 120;
538
457
  /**
539
458
  * Capture clicks and input events as session events. Listens in the capture
@@ -576,13 +495,29 @@ function textOf(el) {
576
495
  return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
577
496
  }
578
497
  //#endregion
579
- //#region src/replay/events.ts
580
- const FLUSH_INTERVAL_MS = 5e3;
581
- const FLUSH_BYTES = 64 * 1024;
498
+ //#region ../browser-session/src/replay/util.ts
499
+ /** Approximate byte size of an event for flush-threshold accounting. Falls back
500
+ * to a fixed estimate for values that can't be serialized (e.g. cycles). */
501
+ function approximateSize(value) {
502
+ try {
503
+ return JSON.stringify(value).length;
504
+ } catch {
505
+ return 256;
506
+ }
507
+ }
508
+ //#endregion
509
+ //#region ../browser-session/src/replay/events.ts
510
+ const FLUSH_INTERVAL_MS$1 = 5e3;
511
+ const FLUSH_BYTES$1 = 64 * 1024;
582
512
  const ZERO_TRACE_ID = "00000000000000000000000000000000";
583
- /** The OTel trace id of the active span, or undefined when none is active. */
513
+ let traceIdProvider = () => void 0;
514
+ /** Wire the host SDK's active-trace-id lookup into event capture. */
515
+ function setActiveTraceIdProvider(provider) {
516
+ traceIdProvider = provider;
517
+ }
518
+ /** The trace id of the active span, or undefined when none is active. */
584
519
  function activeTraceId() {
585
- const id = trace.getActiveSpan()?.spanContext().traceId;
520
+ const id = traceIdProvider();
586
521
  return id && id !== ZERO_TRACE_ID ? id : void 0;
587
522
  }
588
523
  /**
@@ -600,7 +535,7 @@ function startEventCapture(config, sessionId) {
600
535
  seq: seq++
601
536
  });
602
537
  bufferBytes += approximateSize(ev);
603
- if (bufferBytes >= FLUSH_BYTES) flush();
538
+ if (bufferBytes >= FLUSH_BYTES$1) flush();
604
539
  };
605
540
  const flush = async (keepalive = false) => {
606
541
  if (buffer.length === 0) return;
@@ -618,7 +553,7 @@ function startEventCapture(config, sessionId) {
618
553
  installNetworkCapture(emit, ignoreUrl),
619
554
  installErrorCapture(emit)
620
555
  ];
621
- const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
556
+ const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS$1);
622
557
  return {
623
558
  stop: () => {
624
559
  clearInterval(flushTimer);
@@ -649,6 +584,234 @@ function toRow(sessionId, ev, seq) {
649
584
  };
650
585
  }
651
586
  //#endregion
587
+ //#region ../browser-session/src/replay/record.ts
588
+ const FULL_SNAPSHOT = 2;
589
+ const INCREMENTAL = 3;
590
+ const SOURCE_MOUSE_INTERACTION = 2;
591
+ const MOUSE_CLICK = 2;
592
+ const FLUSH_INTERVAL_MS = 5e3;
593
+ const FLUSH_BYTES = 100 * 1024;
594
+ const CHECKOUT_EVERY_MS = 3e4;
595
+ function startRecording(config, sessionId) {
596
+ let buffer = [];
597
+ let bufferBytes = 0;
598
+ let bufferHasCheckpoint = false;
599
+ let clickCount = 0;
600
+ const flush = async (keepalive = false) => {
601
+ if (buffer.length === 0) return;
602
+ markActivity();
603
+ const events = buffer;
604
+ const isCheckpoint = bufferHasCheckpoint;
605
+ const seq = nextChunkSeq();
606
+ const first = events[0].timestamp;
607
+ const last = events[events.length - 1].timestamp;
608
+ buffer = [];
609
+ bufferBytes = 0;
610
+ bufferHasCheckpoint = false;
611
+ const gzipped = await gzip(new TextEncoder().encode(JSON.stringify(events)));
612
+ await postSessionBlob(config, {
613
+ sessionId,
614
+ chunkSeq: seq,
615
+ isCheckpoint,
616
+ eventCount: events.length,
617
+ durationMs: Math.max(0, last - first)
618
+ }, gzipped, keepalive);
619
+ };
620
+ const stop = record({
621
+ emit: (event, isCheckpoint) => {
622
+ const e = event;
623
+ if (isCheckpoint === true || e.type === FULL_SNAPSHOT) bufferHasCheckpoint = true;
624
+ if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
625
+ buffer.push(e);
626
+ bufferBytes += approximateSize(e);
627
+ if (bufferBytes >= FLUSH_BYTES) flush();
628
+ },
629
+ maskAllInputs: config.maskAllInputs,
630
+ ...config.maskAllText ? { maskTextSelector: "*" } : {},
631
+ checkoutEveryNms: CHECKOUT_EVERY_MS
632
+ });
633
+ const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
634
+ return {
635
+ stop: () => {
636
+ clearInterval(flushTimer);
637
+ stop?.();
638
+ },
639
+ flush,
640
+ getClickCount: () => clickCount
641
+ };
642
+ }
643
+ //#endregion
644
+ //#region ../browser-session/src/replay-session.ts
645
+ /**
646
+ * Start recording the current browser session. Publishes the session sink,
647
+ * posts an `active` metadata row, and installs visibility handlers:
648
+ * hidden → flush + `ended` row (with observed trace ids) + stop capture;
649
+ * visible → re-resolve the session (rotating if idle-expired), republish the
650
+ * sink, restart capture, post a fresh `active` row. Metadata versions are
651
+ * monotonic per session, so the latest row always wins on the backend.
652
+ *
653
+ * Returns undefined outside a browser. Sampling is the caller's decision.
654
+ */
655
+ function startReplaySession(options) {
656
+ if (typeof window === "undefined") return void 0;
657
+ const engineConfig = {
658
+ endpoint: options.endpoint.replace(/\/$/, ""),
659
+ ingestKey: options.ingestKey,
660
+ maskAllInputs: options.maskAllInputs,
661
+ maskAllText: options.maskAllText
662
+ };
663
+ const session = getSession();
664
+ let currentSessionId = session.id;
665
+ let currentStartedAt = new Date(session.startedAt);
666
+ publishSessionSink(currentSessionId);
667
+ let recorder;
668
+ let events;
669
+ let stopped = false;
670
+ const postMeta = (status, clickCount, keepalive = false) => postSessionMeta(engineConfig, buildSessionMetaRow({
671
+ sessionId: currentSessionId,
672
+ startedAt: currentStartedAt,
673
+ version: nextMetaVersion(),
674
+ status,
675
+ serviceName: options.serviceName,
676
+ userId: options.getUserId?.(),
677
+ environment: options.environment,
678
+ serviceVersion: options.serviceVersion,
679
+ clickCount: clickCount ?? 0,
680
+ traceIds: status === "ended" ? getObservedTraceIds() : void 0
681
+ }), keepalive);
682
+ const start = () => {
683
+ recorder = startRecording(engineConfig, currentSessionId);
684
+ events = startEventCapture(engineConfig, currentSessionId);
685
+ postMeta("active", null);
686
+ };
687
+ const suspend = () => {
688
+ if (!recorder || !events) return;
689
+ recorder.flush(true);
690
+ events.flush(true);
691
+ postMeta("ended", recorder.getClickCount(), true);
692
+ recorder.stop();
693
+ events.stop();
694
+ recorder = void 0;
695
+ events = void 0;
696
+ };
697
+ const resume = () => {
698
+ if (stopped || recorder) return;
699
+ const next = getSession();
700
+ if (next.id !== currentSessionId) {
701
+ currentSessionId = next.id;
702
+ currentStartedAt = new Date(next.startedAt);
703
+ publishSessionSink(currentSessionId);
704
+ }
705
+ start();
706
+ };
707
+ const onVisibilityChange = () => {
708
+ if (document.visibilityState === "hidden") suspend();
709
+ else resume();
710
+ };
711
+ const onPageHide = () => suspend();
712
+ start();
713
+ document.addEventListener("visibilitychange", onVisibilityChange);
714
+ window.addEventListener("pagehide", onPageHide);
715
+ return {
716
+ sessionId: currentSessionId,
717
+ shutdown: async () => {
718
+ stopped = true;
719
+ document.removeEventListener("visibilitychange", onVisibilityChange);
720
+ window.removeEventListener("pagehide", onPageHide);
721
+ if (recorder) await recorder.flush(true);
722
+ if (events) await events.flush(true);
723
+ recorder?.stop();
724
+ events?.stop();
725
+ recorder = void 0;
726
+ events = void 0;
727
+ }
728
+ };
729
+ }
730
+ //#endregion
731
+ //#region src/config.ts
732
+ const DEFAULT_ENDPOINT = "https://ingest.maple.dev";
733
+ function normalizeUserId(userId) {
734
+ return userId ? userId : void 0;
735
+ }
736
+ function resolveConfig(config) {
737
+ return {
738
+ ingestKey: config.ingestKey,
739
+ serviceName: config.serviceName,
740
+ endpoint: (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
741
+ serviceNamespace: config.serviceNamespace,
742
+ serviceVersion: config.serviceVersion,
743
+ environment: config.environment,
744
+ userId: normalizeUserId(config.userId),
745
+ tracingEnabled: config.tracing?.enabled ?? true,
746
+ tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
747
+ replayEnabled: config.replay?.enabled ?? true,
748
+ replaySampleRate: config.replay?.sampleRate ?? 1,
749
+ maskAllInputs: config.privacy?.maskAllInputs ?? true,
750
+ maskAllText: config.privacy?.maskAllText ?? false
751
+ };
752
+ }
753
+ //#endregion
754
+ //#region src/tracing.ts
755
+ /**
756
+ * Captures every span's trace id into the session sink. Lightweight — runs
757
+ * alongside the BatchSpanProcessor, does no export of its own.
758
+ */
759
+ var TraceIdCollector = class {
760
+ constructor(getUserId = () => void 0) {
761
+ this.getUserId = getUserId;
762
+ }
763
+ onStart(span) {
764
+ recordTraceId(span.spanContext().traceId);
765
+ const userId = this.getUserId();
766
+ if (userId !== void 0) span.setAttribute("user.id", userId);
767
+ }
768
+ onEnd(_span) {}
769
+ forceFlush() {
770
+ return Promise.resolve();
771
+ }
772
+ shutdown() {
773
+ return Promise.resolve();
774
+ }
775
+ };
776
+ /**
777
+ * Set up browser OTel tracing exporting to Maple's ingest, tagging the resource
778
+ * with the shared `session.id`. When `tracingInstrumentFetch` is true, fetch()
779
+ * calls are auto-instrumented and their trace ids feed the session. Disable it
780
+ * when an external tracer (e.g. the Effect client SDK) already instruments
781
+ * requests — that tracer feeds the session via the published sink instead, and
782
+ * this avoids redundant duplicate network spans. Returns a shutdown function.
783
+ */
784
+ function setupTracing(config, sessionId) {
785
+ const attributes = {
786
+ [ATTR_SERVICE_NAME]: config.serviceName,
787
+ "maple.sdk.type": "browser",
788
+ "session.id": sessionId
789
+ };
790
+ if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
791
+ if (config.serviceVersion) {
792
+ attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
793
+ attributes["deployment.commit_sha"] = config.serviceVersion;
794
+ }
795
+ if (config.environment) {
796
+ attributes["deployment.environment"] = config.environment;
797
+ attributes["deployment.environment.name"] = config.environment;
798
+ }
799
+ const exporter = new OTLPTraceExporter({
800
+ url: `${config.endpoint}/v1/traces`,
801
+ headers: { Authorization: `Bearer ${config.ingestKey}` }
802
+ });
803
+ const provider = new WebTracerProvider({
804
+ resource: resourceFromAttributes(attributes),
805
+ spanProcessors: [new TraceIdCollector(() => config.userId), new BatchSpanProcessor(exporter)]
806
+ });
807
+ provider.register();
808
+ if (config.tracingInstrumentFetch) registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] });
809
+ return () => provider.shutdown();
810
+ }
811
+ function escapeRegExp(value) {
812
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
813
+ }
814
+ //#endregion
652
815
  //#region src/init.ts
653
816
  let active;
654
817
  let activeConfig;
@@ -656,6 +819,10 @@ let activeConfig;
656
819
  * Initialize Maple browser telemetry: OTel tracing + (sampled) rrweb session
657
820
  * replay, both tagged with one shared session id so a trace can link to its
658
821
  * replay and vice versa. Idempotent — repeated calls return the live handle.
822
+ *
823
+ * The replay lifecycle (suspend on tab-hidden, resume on visible, session
824
+ * metadata rows) lives in `@maple/browser-session` and is shared with the
825
+ * Effect client SDK's `replay` option.
659
826
  */
660
827
  function init(rawConfig) {
661
828
  if (active) return active;
@@ -666,26 +833,26 @@ function init(rawConfig) {
666
833
  const config = resolveConfig(rawConfig);
667
834
  activeConfig = config;
668
835
  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;
836
+ publishSessionSink(session.id);
837
+ setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
838
+ const shutdownTracing = config.tracingEnabled ? setupTracing(config, session.id) : void 0;
673
839
  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
- }
840
+ let replay;
841
+ if (recordReplay) replay = startReplaySession({
842
+ endpoint: config.endpoint,
843
+ ingestKey: config.ingestKey,
844
+ serviceName: config.serviceName,
845
+ environment: config.environment,
846
+ serviceVersion: config.serviceVersion,
847
+ maskAllInputs: config.maskAllInputs,
848
+ maskAllText: config.maskAllText,
849
+ getUserId: () => activeConfig?.userId
850
+ });
682
851
  const handle = {
683
- sessionId,
852
+ sessionId: session.id,
684
853
  shutdown: async () => {
685
- if (recorder) await recorder.flush(true);
686
- if (events) await events.flush(true);
687
- recorder?.stop();
688
- events?.stop();
854
+ await replay?.shutdown();
855
+ replay = void 0;
689
856
  await shutdownTracing?.();
690
857
  active = void 0;
691
858
  activeConfig = void 0;
@@ -695,64 +862,14 @@ function init(rawConfig) {
695
862
  return handle;
696
863
  }
697
864
  /**
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)`).
865
+ * Attach, replace, or clear the user id on the active session. Idempotent and
866
+ * safe to call on every render. Future browser-created spans read this value
867
+ * when they start, and future session metadata rows read it when they post.
705
868
  */
706
869
  function identify(userId) {
707
870
  if (typeof window === "undefined") return;
708
871
  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;
872
+ activeConfig.userId = normalizeUserId(userId);
756
873
  }
757
874
  //#endregion
758
875
  //#region src/index.ts
package/package.json CHANGED
@@ -1,13 +1,21 @@
1
1
  {
2
2
  "name": "@maple-dev/browser",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
+ "keywords": [
6
+ "maple",
7
+ "observability",
8
+ "opentelemetry",
9
+ "rrweb",
10
+ "rum",
11
+ "session-replay"
12
+ ],
5
13
  "license": "MIT",
6
- "type": "module",
7
14
  "files": [
8
15
  "dist",
9
16
  "README.md"
10
17
  ],
18
+ "type": "module",
11
19
  "exports": {
12
20
  ".": {
13
21
  "types": "./dist/index.d.mts",
@@ -31,16 +39,9 @@
31
39
  "rrweb": "^2.0.0-alpha.18"
32
40
  },
33
41
  "devDependencies": {
42
+ "@maple/browser-session": "0.1.0",
34
43
  "tsdown": "^0.21.7",
35
44
  "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
- ]
45
+ "vitest": "^4.1.9"
46
+ }
46
47
  }