@maple-dev/browser 0.10.0 → 0.11.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
@@ -68,8 +68,9 @@ fingerprint to one contentless issue that buries the real ones. Add
68
68
 
69
69
  ### Failed HTTP requests
70
70
 
71
- `errors: { captureHttpStatus: [[500, 599]] }` makes those `fetch`/XHR responses errors, typed by
72
- status. By default a response status alone is not an error; a network failure always is.
71
+ `fetch`/XHR responses with a 4xx or 5xx status are errors, typed by status, as the HTTP semantic
72
+ conventions say for client spans. Narrow that with `errors: { captureHttpStatus: [[500, 599]] }`.
73
+ A network failure is always an error.
73
74
 
74
75
  ### Breadcrumbs
75
76
 
@@ -1,4 +1,4 @@
1
- import { O as safeEmit } from "./sink-BOR9_z4b.mjs";
1
+ import { M as safeEmit } from "./sink-DbEuphC-.mjs";
2
2
  //#region ../browser-session/src/replay/capture/console.ts
3
3
  const LEVELS = [
4
4
  "log",
@@ -1,11 +1,10 @@
1
- import { E as selectorOf, F as scrubUrl, l as onSessionEvent, w as sdkHint, x as ingestHeaders } from "./sink-BOR9_z4b.mjs";
2
- import { _ as consentRevokedAt, a as detachLogSink, c as resourceAttributes, d as attachSpanStash, f as claimPageSignal, g as consentAllowedSince, h as severityOf, i as attachLogSink, l as SDK_NAME, m as Severity, n as navigationSpanAt, o as emitLog, p as onErrorRecorded, r as onDocumentPageload, s as liveMapleTracer, u as SDK_VERSION, v as hasConsent, y as onConsentChange } from "./src-Bzmp8rVr.mjs";
3
- import { t as installConsoleCapture } from "./console-BrjBnOiX.mjs";
1
+ import { A as selectorOf, G as scrubUrl, K as mayUseStorage, L as consentAllowedSince, O as sdkHint, R as consentRevokedAt, S as ingestHeaders, V as onConsentChange, l as onSessionEvent, z as hasConsent } from "./sink-DbEuphC-.mjs";
2
+ import { _ as severityOf, a as detachLogSink, c as resourceAttributes, d as OtlpExporter, f as newestFirstOnExit, g as Severity, h as onErrorRecorded, i as attachLogSink, l as SDK_NAME, m as claimPageSignal, n as navigationSpanAt, o as emitLog, p as attachSpanStash, r as onDocumentPageload, s as liveMapleTracer, u as SDK_VERSION } from "./src-BiHeZJ9l.mjs";
3
+ import { t as installConsoleCapture } from "./console-Bkd3hBGp.mjs";
4
4
  import { ROOT_CONTEXT, TraceFlags, context, trace } from "@opentelemetry/api";
5
+ import { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer";
5
6
  import { resourceFromAttributes } from "@opentelemetry/resources";
6
7
  import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
7
- import { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer";
8
- import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
9
8
  import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
10
9
  //#region ../sdk-core/src/browser/breadcrumbs.ts
11
10
  const MAX_CRUMBS = 50;
@@ -203,6 +202,11 @@ function fingerprint(value) {
203
202
  return (hash >>> 0).toString(16);
204
203
  }
205
204
  function startOfflineQueue(config) {
205
+ if (!mayUseStorage()) return {
206
+ stash: () => {},
207
+ resend: () => Promise.resolve(),
208
+ stop: () => {}
209
+ };
206
210
  const db = openDb();
207
211
  const headers = {
208
212
  ...config.headers,
@@ -587,14 +591,11 @@ var OfflineLogExporter = class {
587
591
  };
588
592
  /** Start the OTel logs pipeline and drain the eager queue into it. Returns a shutdown. */
589
593
  function startLogs(config, stashOffline) {
590
- const otlp = new OTLPLogExporter({
591
- url: `${config.endpoint}/v1/logs`,
592
- headers: ingestHeaders({
593
- ingestKey: config.ingestKey,
594
- sdk: sdkHint(SDK_NAME, SDK_VERSION)
595
- })
596
- });
597
- const exporter = new ConsentLogExporter(stashOffline ? new OfflineLogExporter(otlp, stashOffline) : otlp);
594
+ const otlp = new OtlpExporter(`${config.endpoint}/v1/logs`, ingestHeaders({
595
+ ingestKey: config.ingestKey,
596
+ sdk: sdkHint(SDK_NAME, SDK_VERSION)
597
+ }), JsonLogsSerializer);
598
+ const exporter = new ConsentLogExporter(newestFirstOnExit(stashOffline ? new OfflineLogExporter(otlp, stashOffline) : otlp, JsonLogsSerializer));
598
599
  const provider = new LoggerProvider({
599
600
  resource: resourceFromAttributes(resourceAttributes(config)),
600
601
  processors: [new BatchLogRecordProcessor({
package/dist/index.d.mts CHANGED
@@ -1,4 +1,7 @@
1
1
  import "@opentelemetry/api";
2
+ //#region ../browser-session/src/platform/persistence.d.ts
3
+ type Persistence = "browser" | "memory";
4
+ //#endregion
2
5
  //#region ../browser-session/src/identity/identity.d.ts
3
6
  /**
4
7
  * Who the current visitor is.
@@ -57,8 +60,9 @@ interface ErrorFilterOptions {
57
60
  readonly beforeCapture?: (error: Error, hint: ErrorFilterHint) => boolean;
58
61
  /**
59
62
  * HTTP response statuses that make a client request span an error (and so an
60
- * issue), e.g. `[[500, 599]]`. Default none: a response status alone is not
61
- * an error, and a network failure always is.
63
+ * issue). Default `[[400, 599]]`, as the HTTP semantic conventions say for
64
+ * client spans; narrow it, e.g. `[[500, 599]]`, to count fewer. A network
65
+ * failure is always an error.
62
66
  */
63
67
  readonly captureHttpStatus?: ReadonlyArray<HttpStatusRange>;
64
68
  /**
@@ -232,6 +236,13 @@ interface MapleBrowserConfig extends SignalOptions {
232
236
  * purges any id already stored.
233
237
  */
234
238
  readonly persistVisitorId?: boolean;
239
+ /**
240
+ * `"memory"` keeps everything in memory: no cookies, localStorage,
241
+ * sessionStorage or IndexedDB, and no visitor id. Each page load is a new
242
+ * session, so a reload splits a replay, and `transport.offline` keeps
243
+ * nothing. Default `"browser"`.
244
+ */
245
+ readonly persistence?: Persistence;
235
246
  /**
236
247
  * Scope the visitor-id cookie to the registered domain, so a marketing site
237
248
  * and an app on sibling subdomains (`example.com` and `app.example.com`)
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as MapleBrowser } from "./src-Bzmp8rVr.mjs";
1
+ import { t as MapleBrowser } from "./src-BiHeZJ9l.mjs";
2
2
  export { MapleBrowser };
package/dist/react.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as MapleBrowser } from "./src-Bzmp8rVr.mjs";
1
+ import { t as MapleBrowser } from "./src-BiHeZJ9l.mjs";
2
2
  import { Component } from "react";
3
3
  //#region src/react.ts
4
4
  /** Component stacks can run to hundreds of lines; the top of it names the failing tree. */
@@ -1,5 +1,6 @@
1
- import { C as postSessionMeta, D as BLOCK_SELECTOR, F as scrubUrl, O as safeEmit, S as postSessionBlob, T as warnDropped, b as gzip, d as startEventSink, g as nextChunkSeq, h as markReplayTriggered, j as withStartedTraceId, k as activeTraceId, m as markActivity, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink } from "./sink-BOR9_z4b.mjs";
2
- import { t as installConsoleCapture } from "./console-BrjBnOiX.mjs";
1
+ import { D as reserveKeepalive, F as withStartedTraceId, G as scrubUrl, K as mayUseStorage, M as safeEmit, N as activeTraceId, R as consentRevokedAt, T as postSessionMeta, W as isJsonObject, d as startEventSink, g as nextChunkSeq, h as markReplayTriggered, j as BLOCK_SELECTOR, k as warnDropped, m as markActivity, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, w as postSessionBlob, x as gzip } from "./sink-DbEuphC-.mjs";
2
+ import { v as PENDING_CHUNK_KEY, y as clearPendingChunk } from "./src-BiHeZJ9l.mjs";
3
+ import { t as installConsoleCapture } from "./console-Bkd3hBGp.mjs";
3
4
  import { record } from "rrweb";
4
5
  //#region ../browser-session/src/replay/capture/network.ts
5
6
  /** Only text is worth keeping; a body that is an image or a stream is not read. */
@@ -209,13 +210,76 @@ function warnBufferDropped(bytes) {
209
210
  lastDropWarnAt = now;
210
211
  console.warn(`[maple] session replay buffer exceeded ${MAX_BUFFER_BYTES} bytes (dropping ${bytes} buffered bytes; recording continues from the next full snapshot)`);
211
212
  }
213
+ const MAX_PENDING_AGE_MS = 6e5;
214
+ const PAGE_STARTED_AT = Date.now();
215
+ /** How long a stored chunk that found the keepalive budget full waits before its one re-attempt. */
216
+ const KEEPALIVE_RETRY_MS = 1e3;
217
+ function isPendingChunk(value) {
218
+ return isJsonObject(value) && typeof value.sessionId === "string" && typeof value.chunkSeq === "number" && typeof value.isCheckpoint === "boolean" && typeof value.eventCount === "number" && typeof value.durationMs === "number" && typeof value.body === "string" && typeof value.createdAt === "number" && typeof value.target === "string";
219
+ }
220
+ /** Endpoint plus an FNV-1a hash of the key, so the key itself is not stored. */
221
+ function targetOf(config) {
222
+ let hash = 2166136261;
223
+ for (const char of config.ingestKey ?? "") hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
224
+ return `${config.endpoint}|${hash >>> 0}`;
225
+ }
226
+ function readPending() {
227
+ if (!mayUseStorage()) return void 0;
228
+ try {
229
+ const parsed = JSON.parse(window.sessionStorage.getItem("maple.replay.pending") ?? "null");
230
+ return isPendingChunk(parsed) ? parsed : void 0;
231
+ } catch {
232
+ return;
233
+ }
234
+ }
235
+ /**
236
+ * Keep `chunk` unless one is already waiting. False when it was not kept
237
+ * (storage blocked or full): it is then only as durable as its upload.
238
+ */
239
+ function storePending(chunk) {
240
+ if (!mayUseStorage()) return false;
241
+ try {
242
+ if (readPending()) return false;
243
+ window.sessionStorage.setItem(PENDING_CHUNK_KEY, JSON.stringify(chunk));
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
212
249
  /**
213
- * gzip and POST one chunk. `seq` is claimed here, monotonic across reloads
250
+ * Remove the stored chunk if it is `meta`'s, reporting whether it was. Whoever
251
+ * removes it sends it, so a seq is posted once: ingest appends an index row per POST.
252
+ */
253
+ function takePending(meta) {
254
+ const chunk = readPending();
255
+ return chunk?.sessionId === meta.sessionId && chunk.chunkSeq === meta.chunkSeq && clearPendingChunk();
256
+ }
257
+ /** Send the chunk an earlier flush left behind, if it is still this recorder's to send; otherwise drop it. */
258
+ function sendPendingChunk(config, sessionId) {
259
+ const chunk = readPending();
260
+ if (!chunk) return;
261
+ if (chunk.target === targetOf(config) && chunk.createdAt > consentRevokedAt() && (chunk.createdAt >= PAGE_STARTED_AT || chunk.sessionId === sessionId && Date.now() - chunk.createdAt <= MAX_PENDING_AGE_MS)) compressAndPost(config, chunk, chunk.body, false, true);
262
+ else clearPendingChunk();
263
+ }
264
+ /**
265
+ * Claim a seq and upload one chunk. The seq is monotonic across reloads
214
266
  * (persisted on the session record), so a refresh continues the sequence
215
267
  * instead of overwriting the previous load's blobs.
216
268
  */
217
- async function uploadChunk(config, sessionId, body, chunk, keepalive) {
218
- const chunkSeq = nextChunkSeq();
269
+ function uploadChunk(config, sessionId, body, chunk, keepalive) {
270
+ const meta = {
271
+ sessionId,
272
+ chunkSeq: nextChunkSeq(),
273
+ ...chunk
274
+ };
275
+ return compressAndPost(config, meta, body, keepalive, keepalive && storePending({
276
+ ...meta,
277
+ body,
278
+ createdAt: Date.now(),
279
+ target: targetOf(config)
280
+ }));
281
+ }
282
+ async function compressAndPost(config, meta, body, keepalive, stored) {
219
283
  let gzipped;
220
284
  try {
221
285
  gzipped = await gzip(new TextEncoder().encode(body));
@@ -223,13 +287,18 @@ async function uploadChunk(config, sessionId, body, chunk, keepalive) {
223
287
  warnDropped("chunk compression", error);
224
288
  return;
225
289
  }
226
- return postSessionBlob(config, {
227
- sessionId,
228
- chunkSeq,
229
- ...chunk
230
- }, gzipped, keepalive);
290
+ if (stored) {
291
+ if (keepalive) {
292
+ const release = reserveKeepalive(true, gzipped.byteLength);
293
+ if (release) release();
294
+ else await new Promise((resolve) => setTimeout(resolve, KEEPALIVE_RETRY_MS));
295
+ }
296
+ if (!takePending(meta)) return void 0;
297
+ }
298
+ return postSessionBlob(config, meta, gzipped, keepalive);
231
299
  }
232
300
  function startRecording(config, sessionId) {
301
+ sendPendingChunk(config, sessionId);
233
302
  let parts = [];
234
303
  let bufferBytes = 0;
235
304
  let bufferHasCheckpoint = false;
@@ -1,3 +1,13 @@
1
+ //#region ../browser-session/src/platform/persistence.ts
2
+ const MEMORY_ONLY_KEY = "__MAPLE_BROWSER_MEMORY_ONLY__";
3
+ function configurePersistence(persistence) {
4
+ if (persistence === "memory") globalThis[MEMORY_ONLY_KEY] = true;
5
+ }
6
+ /** Whether the SDK may write anything to the device. */
7
+ function mayUseStorage() {
8
+ return globalThis[MEMORY_ONLY_KEY] !== true;
9
+ }
10
+ //#endregion
1
11
  //#region ../browser-session/src/platform/url-privacy.ts
2
12
  const REDACTED = "REDACTED";
3
13
  const SENSITIVE_PARAM = /^(access_token|id_token|refresh_token|token|token_hash|auth|authorization|code|oobcode|state|password|passwd|pwd|pass|secret|client_secret|api_key|apikey|key|signature|sig|otp|jwt|session|sessionid|session_id|ticket|__clerk_ticket|reset_token|magic|nonce|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
@@ -141,11 +151,25 @@ function setRawCookie(name, value, domain, maxAgeSeconds) {
141
151
  return false;
142
152
  }
143
153
  }
144
- /** Expire `name` on the shared domain and host-only: it may predate a narrowed scope. */
154
+ /**
155
+ * Expire `name` host-only and on every parent domain it could have been scoped
156
+ * to: it may predate a narrowed scope. Expiring a cookie that does not exist
157
+ * stores nothing, unlike the domain probe, so a memory-only opt-out stays clean.
158
+ */
145
159
  function deleteRawCookie(name) {
146
- const domain = cookieDomain();
147
- setRawCookie(name, "", domain, 0);
148
- if (domain) setRawCookie(name, "", "", 0);
160
+ setRawCookie(name, "", "", 0);
161
+ for (const domain of candidateDomains()) setRawCookie(name, "", domain, 0);
162
+ if (cookieDomainOverride) setRawCookie(name, "", cookieDomainOverride, 0);
163
+ }
164
+ /** Parent domains of this host, broadest first. Empty for single-label hosts and IPs. */
165
+ function candidateDomains() {
166
+ if (typeof location === "undefined") return [];
167
+ const hostname = location.hostname;
168
+ if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return [];
169
+ const parts = hostname.split(".");
170
+ const candidates = [];
171
+ for (let i = parts.length - 2; i >= 0; i--) candidates.push(parts.slice(i).join("."));
172
+ return candidates;
149
173
  }
150
174
  /**
151
175
  * The broadest domain this browser will actually accept a cookie for, found by
@@ -158,13 +182,8 @@ function deleteRawCookie(name) {
158
182
  * for bare IPs, neither of which can carry a `Domain=` attribute.
159
183
  */
160
184
  function probeCookieDomain() {
161
- if (typeof document === "undefined" || typeof location === "undefined") return "";
162
- const hostname = location.hostname;
163
- if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
164
- const parts = hostname.split(".");
165
- if (parts.length < 2) return "";
166
- for (let i = parts.length - 2; i >= 0; i--) {
167
- const candidate = parts.slice(i).join(".");
185
+ if (typeof document === "undefined") return "";
186
+ for (const candidate of candidateDomains()) {
168
187
  const probe = "__maple_probe";
169
188
  if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
170
189
  setRawCookie(probe, "", candidate, 0);
@@ -482,6 +501,146 @@ function setVisitorTracking(nextEnabled) {
482
501
  clearVisitClaim();
483
502
  }
484
503
  //#endregion
504
+ //#region ../browser-session/src/identity/consent.ts
505
+ /**
506
+ * Consent gating for session capture.
507
+ *
508
+ * The posture, deliberately:
509
+ *
510
+ * - `requireConsent` defaults to **false**. Flipping that default would
511
+ * silently stop telemetry for every existing install — an availability
512
+ * incident dressed up as a privacy win. Apps that need a consent gate opt in.
513
+ * - Global Privacy Control **is** honored by default, but only against the
514
+ * persistent visitor id, not the session itself. GPC is a legally recognized
515
+ * signal in several US states and it maps exactly onto the one cross-session
516
+ * identifier this SDK stores; the session-scoped capture that powers replay
517
+ * and error triage is unaffected.
518
+ * - `doNotTrack` is **not** honored by default. It is deprecated, removed from
519
+ * Safari, and ignored across the industry, so respecting it would mostly mean
520
+ * dropping data from users who never intended to opt out. Apps can turn it on.
521
+ */
522
+ /**
523
+ * Consent lives on `globalThis`, for the same reason the event sink does
524
+ * (`events/events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
525
+ * Effect SDK gets **two copies** of this module, and per-copy state would mean
526
+ * `setConsent(true)` on one never releases the other copy's exporters — or, the
527
+ * other way round, one copy capturing while the other believes consent is
528
+ * required. There is one user and one decision, so there is one state.
529
+ */
530
+ const CONSENT_KEY = "__MAPLE_BROWSER_CONSENT__";
531
+ function consentState() {
532
+ const owner = globalThis;
533
+ const existing = owner[CONSENT_KEY];
534
+ if (existing) {
535
+ existing.revokedAt ||= 0;
536
+ return existing;
537
+ }
538
+ const fresh = {
539
+ allowedSince: 0,
540
+ granted: false,
541
+ listeners: /* @__PURE__ */ new Set(),
542
+ requireConsent: false,
543
+ respectDoNotTrack: false,
544
+ revokedAt: 0
545
+ };
546
+ owner[CONSENT_KEY] = fresh;
547
+ return fresh;
548
+ }
549
+ /** When consent was last withdrawn, on any page of this origin. Persisted: the grant time resets every load. */
550
+ const REVOKED_AT_KEY = "maple-consent-revoked-at";
551
+ function writeRevokedAt(at) {
552
+ consentState().revokedAt = at;
553
+ if (!mayUseStorage()) return;
554
+ try {
555
+ localStorage.setItem(REVOKED_AT_KEY, String(at));
556
+ } catch {}
557
+ }
558
+ /** Epoch ms of the last consent withdrawal on this origin, 0 if never. Data kept from before it must not be sent. */
559
+ function consentRevokedAt() {
560
+ const inMemory = consentState().revokedAt;
561
+ if (!mayUseStorage()) return inMemory;
562
+ try {
563
+ return Math.max(inMemory, Number(localStorage.getItem(REVOKED_AT_KEY)) || 0);
564
+ } catch {
565
+ return inMemory;
566
+ }
567
+ }
568
+ function updateEffectiveConsent(previous) {
569
+ const state = consentState();
570
+ const allowed = hasConsent();
571
+ if (allowed === previous) return;
572
+ state.allowedSince = allowed ? Date.now() : Number.POSITIVE_INFINITY;
573
+ for (const listener of state.listeners) try {
574
+ listener(allowed);
575
+ } catch {}
576
+ }
577
+ /**
578
+ * Apply the host app's privacy config. Call before anything captures.
579
+ *
580
+ * Both SDKs call this at startup, and an app can init one of them without a
581
+ * `privacy` block — so the gates only ever **tighten**. Letting the second
582
+ * caller's absent option reset `requireConsent` back to false would silently
583
+ * disable a gate the first caller asked for, and the resulting
584
+ * denied→allowed transition would start capture on a user who never consented.
585
+ * To genuinely relax a gate, reload without it.
586
+ */
587
+ function configurePrivacy(options) {
588
+ const state = consentState();
589
+ const previous = hasConsent();
590
+ state.requireConsent = state.requireConsent || (options?.requireConsent ?? false);
591
+ state.respectDoNotTrack = state.respectDoNotTrack || (options?.respectDoNotTrack ?? false);
592
+ configurePersistence(options?.persistence);
593
+ configureVisitorCookie({
594
+ crossSubdomainCookie: options?.crossSubdomainCookie,
595
+ cookieDomain: options?.cookieDomain
596
+ });
597
+ if (options?.sanitizeUrl) addUrlSanitizer(options.sanitizeUrl);
598
+ updateEffectiveConsent(previous);
599
+ }
600
+ /** Record the user's consent decision. No-op unless `requireConsent` is set. */
601
+ function setConsent(nextGranted) {
602
+ const previous = hasConsent();
603
+ const state = consentState();
604
+ if (state.granted && !nextGranted) writeRevokedAt(Date.now());
605
+ state.granted = nextGranted;
606
+ updateEffectiveConsent(previous);
607
+ }
608
+ /** Whether capture may proceed at all. */
609
+ function hasConsent() {
610
+ const state = consentState();
611
+ return !state.requireConsent || state.granted;
612
+ }
613
+ /**
614
+ * Subscribe to effective capture permission changes. The listener is invoked
615
+ * only when capture actually transitions between allowed and denied; changing
616
+ * the stored decision while `requireConsent` is off is deliberately silent.
617
+ */
618
+ function onConsentChange(listener) {
619
+ const { listeners } = consentState();
620
+ listeners.add(listener);
621
+ return () => listeners.delete(listener);
622
+ }
623
+ /**
624
+ * Earliest epoch-ms timestamp telemetry may export for the current grant.
625
+ * `Infinity` means capture is denied. Exporters use this to discard anything
626
+ * buffered before a late grant or across a revoke/re-grant cycle.
627
+ */
628
+ function consentAllowedSince() {
629
+ return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
630
+ }
631
+ /**
632
+ * Whether a *persistent* identifier may be stored. Separate from `hasConsent`
633
+ * because GPC/DNT suppress the cross-session id without suppressing capture.
634
+ */
635
+ function mayPersistIdentifier() {
636
+ if (!hasConsent() || !mayUseStorage()) return false;
637
+ if (typeof navigator === "undefined") return true;
638
+ const nav = navigator;
639
+ if (nav.globalPrivacyControl === true) return false;
640
+ if (consentState().respectDoNotTrack && nav.doNotTrack === "1") return false;
641
+ return true;
642
+ }
643
+ //#endregion
485
644
  //#region ../browser-session/src/events/trace-id.ts
486
645
  const ZERO_TRACE_ID = "00000000000000000000000000000000";
487
646
  let traceIdProvider = () => void 0;
@@ -676,25 +835,38 @@ function warnDropped(what, error) {
676
835
  const now = Date.now();
677
836
  if (now - (lastWarnAt.get(what) ?? 0) < 3e4) return;
678
837
  lastWarnAt.set(what, now);
679
- console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
838
+ console.warn(`[maple] session ${what} failed:`, error);
680
839
  }
681
840
  /**
682
841
  * Combined budget for this SDK's in-flight `keepalive` bodies.
683
842
  *
684
843
  * The Fetch spec caps the *combined* in-flight keepalive body at 64 KiB per
685
- * page, and the browser rejects a request that would cross it. On the way out
686
- * up to three of our writes go at once (metadata row, final events batch, last
687
- * replay chunk), so the budget has to be shared rather than checked per
688
- * request. It stops short of 64 KiB because the OTLP trace exporter spends
689
- * from the same page-wide allowance under its own accounting.
844
+ * document, and the browser rejects a request that would cross it. On the way
845
+ * out several of our writes go at once (the session's final metadata row and
846
+ * events batch, a replay chunk, either SDK's OTLP export), so the budget has
847
+ * to be shared rather than checked per request. OTLP's own share is capped
848
+ * ({@link OTLP_KEEPALIVE_BYTES}), which keeps room for the session rows
849
+ * whichever is issued first.
690
850
  *
691
- * Over the budget a write goes out as a normal request, which the page may or
692
- * may not survive long enough to finish: strictly better than a guaranteed
851
+ * The budget stops short of 64 KiB for keepalive requests it cannot count,
852
+ * the host page's own among them. That is headroom, not a guarantee.
853
+ *
854
+ * Over the budget a write goes out as a normal request, which the browser
855
+ * terminates if the document unloads first: still better than a guaranteed
693
856
  * rejection.
694
857
  */
695
858
  const KEEPALIVE_BUDGET_BYTES = 49152;
696
- /** On `globalThis`: two bundled SDK copies still share one page-wide allowance. */
859
+ /** The most of the budget that OTLP requests hold between them. */
860
+ const OTLP_KEEPALIVE_BYTES = 32768;
861
+ /**
862
+ * Largest body of newest items an OTLP flush sends first from a hidden or
863
+ * unloading document. Two signals' worth leaves about 4 KiB of OTLP's share
864
+ * for a metrics snapshot.
865
+ */
866
+ const OTLP_UNLOAD_TAIL_BYTES = 14336;
867
+ /** On `globalThis`: two bundled SDK copies still share one per-document allowance. */
697
868
  const KEEPALIVE_KEY = "__MAPLE_KEEPALIVE_INFLIGHT__";
869
+ /** `otlpBytes` is absent on a holder created by an SDK copy that predates it. */
698
870
  function keepaliveInflight() {
699
871
  const owner = globalThis;
700
872
  const existing = owner[KEEPALIVE_KEY];
@@ -703,20 +875,28 @@ function keepaliveInflight() {
703
875
  owner[KEEPALIVE_KEY] = fresh;
704
876
  return fresh;
705
877
  }
878
+ /** Keepalive bytes an OTLP request could reserve right now: its own share and the total both apply. */
879
+ function otlpKeepaliveRoom() {
880
+ const inflight = keepaliveInflight();
881
+ return Math.max(0, Math.min(OTLP_KEEPALIVE_BYTES - (inflight.otlpBytes ?? 0), KEEPALIVE_BUDGET_BYTES - inflight.bytes));
882
+ }
706
883
  /**
707
884
  * Reserve `bytes` of the shared keepalive budget. Returns the release function
708
- * when the request may use `keepalive`, `undefined` when it must not.
885
+ * when the request may use `keepalive`, `undefined` when it must not. `otlp`
886
+ * marks an OTLP request, which also counts against {@link OTLP_KEEPALIVE_BYTES}.
709
887
  */
710
- function reserveKeepalive(requested, bytes) {
888
+ function reserveKeepalive(requested, bytes, otlp = false) {
711
889
  if (!requested) return void 0;
712
890
  const inflight = keepaliveInflight();
713
- if (inflight.bytes + bytes > KEEPALIVE_BUDGET_BYTES) return void 0;
891
+ if (bytes > (otlp ? otlpKeepaliveRoom() : KEEPALIVE_BUDGET_BYTES - inflight.bytes)) return void 0;
714
892
  inflight.bytes += bytes;
893
+ if (otlp) inflight.otlpBytes = (inflight.otlpBytes ?? 0) + bytes;
715
894
  let released = false;
716
895
  return () => {
717
896
  if (released) return;
718
897
  released = true;
719
898
  inflight.bytes = Math.max(0, inflight.bytes - bytes);
899
+ if (otlp) inflight.otlpBytes = Math.max(0, (inflight.otlpBytes ?? 0) - bytes);
720
900
  };
721
901
  }
722
902
  /** Body size in bytes: the keepalive limit counts bytes, and `string.length` counts UTF-16 units. */
@@ -727,18 +907,19 @@ function byteLength(body) {
727
907
  * POST to ingest, spending the shared keepalive budget when `keepalive` is
728
908
  * requested. Resolves with the status only: the response body is cancelled
729
909
  * before the reservation is released, because the browser counts a keepalive
730
- * request against the page-wide limit until its response body ends, not
731
- * until headers arrive. Rejects exactly as `fetch` does; callers own the
732
- * error policy.
910
+ * request against the per-document limit until its response body ends, not
911
+ * until headers arrive. Rejects exactly as `fetch` does, including when
912
+ * `signal` aborts; callers own the error policy.
733
913
  */
734
- async function postToIngest(url, headers, body, keepalive) {
735
- const release = reserveKeepalive(keepalive, byteLength(body));
914
+ async function postToIngest(url, headers, body, keepalive, options = {}) {
915
+ const release = body.length > KEEPALIVE_BUDGET_BYTES ? void 0 : reserveKeepalive(keepalive, byteLength(body), options.otlp);
736
916
  try {
737
917
  const response = await fetch(url, {
738
918
  method: "POST",
739
919
  headers,
740
920
  body,
741
- keepalive: release !== void 0
921
+ keepalive: release !== void 0,
922
+ signal: options.signal
742
923
  });
743
924
  await response.body?.cancel().catch(() => {});
744
925
  return {
@@ -783,16 +964,23 @@ async function postSessionMeta(config, row, keepalive = false) {
783
964
  warnDropped("metadata POST", error);
784
965
  });
785
966
  }
786
- /** POST distilled session events (NDJSON, one row per event). Best-effort. */
967
+ /**
968
+ * POST distilled session events (NDJSON, one row per event). Never throws.
969
+ * `"failed"` means no response came back, so the rows may or may not have
970
+ * reached ingest.
971
+ */
787
972
  async function postSessionEvents(config, rows, keepalive = false) {
788
- if (rows.length === 0) return;
973
+ if (rows.length === 0) return "accepted";
789
974
  const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
790
- await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
791
- ...ingestHeaders(config),
792
- "content-type": "application/x-ndjson"
793
- }, body, keepalive).catch((error) => {
975
+ try {
976
+ return (await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
977
+ ...ingestHeaders(config),
978
+ "content-type": "application/x-ndjson"
979
+ }, body, keepalive)).ok ? "accepted" : "rejected";
980
+ } catch (error) {
794
981
  warnDropped("events POST", error);
795
- });
982
+ return "failed";
983
+ }
796
984
  }
797
985
  const SESSION_EXHAUSTED_STATUS = 413;
798
986
  /** POST a gzipped rrweb event chunk. */
@@ -881,7 +1069,7 @@ function isLikelyBot(ua) {
881
1069
  /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
882
1070
  function formatCHDateTime(date) {
883
1071
  const pad = (n, width = 2) => String(n).padStart(width, "0");
884
- return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
1072
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.` + pad(date.getUTCMilliseconds(), 3);
885
1073
  }
886
1074
  /**
887
1075
  * Build one `/v1/sessionReplays/meta` NDJSON row. Shared by `@maple-dev/browser`
@@ -1120,7 +1308,7 @@ function freshRecord(now) {
1120
1308
  };
1121
1309
  }
1122
1310
  function readRecord() {
1123
- if (writesFailed && ephemeral) return ephemeral;
1311
+ if (!mayUseStorage() || writesFailed && ephemeral) return ephemeral;
1124
1312
  try {
1125
1313
  const raw = window.sessionStorage.getItem(STORAGE_KEY);
1126
1314
  if (!raw) return void 0;
@@ -1131,6 +1319,7 @@ function readRecord() {
1131
1319
  }
1132
1320
  function writeRecord(record) {
1133
1321
  ephemeral = record;
1322
+ if (!mayUseStorage()) return;
1134
1323
  try {
1135
1324
  window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
1136
1325
  writesFailed = false;
@@ -1163,6 +1352,7 @@ function tabNonce() {
1163
1352
  return fresh;
1164
1353
  }
1165
1354
  function leaseStorage() {
1355
+ if (!mayUseStorage()) return void 0;
1166
1356
  try {
1167
1357
  return typeof window !== "undefined" ? window.localStorage : void 0;
1168
1358
  } catch {
@@ -1498,6 +1688,12 @@ function onSessionEvent(listener) {
1498
1688
  }
1499
1689
  const FLUSH_INTERVAL_MS = 5e3;
1500
1690
  const FLUSH_BYTES = 65536;
1691
+ /** Events across which an in-flight POST can fail although ingest stored it. */
1692
+ const PAGE_TRANSITIONS = [
1693
+ "beforeunload",
1694
+ "pagehide",
1695
+ "visibilitychange"
1696
+ ];
1501
1697
  /**
1502
1698
  * The sink is a per-session singleton, published on `globalThis` rather than a
1503
1699
  * module-level variable.
@@ -1533,16 +1729,28 @@ function startEventSink(config, sessionId) {
1533
1729
  }
1534
1730
  let buffer = [];
1535
1731
  let bufferBytes = 0;
1732
+ let retry = [];
1536
1733
  let seq = 0;
1537
1734
  let pageViews = 0;
1538
1735
  let clickCount = 0;
1539
1736
  let errorCount = 0;
1737
+ let pageTransitions = 0;
1738
+ const onPageTransition = (event) => {
1739
+ pageTransitions++;
1740
+ if (event.type === "pagehide") retry = [];
1741
+ };
1742
+ const pageTarget = typeof globalThis.addEventListener === "function" ? globalThis : void 0;
1743
+ for (const type of PAGE_TRANSITIONS) pageTarget?.addEventListener(type, onPageTransition);
1540
1744
  const flush = async (keepalive = false) => {
1541
- if (buffer.length === 0) return;
1542
- const batch = buffer;
1745
+ const fresh = buffer.map(({ ev, seq }) => toRow(config, sessionId, ev, seq));
1746
+ const rows = keepalive ? fresh : [...retry, ...fresh];
1747
+ if (rows.length === 0) return;
1748
+ if (!keepalive) retry = [];
1543
1749
  buffer = [];
1544
1750
  bufferBytes = 0;
1545
- await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(config, sessionId, ev, seq)), keepalive);
1751
+ const transitionsAtSend = pageTransitions;
1752
+ if (await postSessionEvents(config, rows, keepalive) !== "failed" || keepalive || pageTransitions !== transitionsAtSend) return;
1753
+ retry = [...fresh, ...retry];
1546
1754
  };
1547
1755
  const emit = (ev) => {
1548
1756
  const session = markActivity();
@@ -1580,6 +1788,7 @@ function startEventSink(config, sessionId) {
1580
1788
  flush,
1581
1789
  stop: () => {
1582
1790
  clearInterval(flushTimer);
1791
+ for (const type of PAGE_TRANSITIONS) pageTarget?.removeEventListener(type, onPageTransition);
1583
1792
  stopNavigation();
1584
1793
  stopBaselineCapture();
1585
1794
  if (holder()[SINK_KEY]?.sink === sink) holder()[SINK_KEY] = void 0;
@@ -1919,8 +2128,9 @@ const observedTraceIdsBySession = /* @__PURE__ */ new Map();
1919
2128
  * unbounded — and the whole set is serialized into the `ended` metadata row,
1920
2129
  * which is written with `keepalive` on the way out. The Fetch spec caps the
1921
2130
  * *combined* keepalive body across in-flight requests at 64 KiB, shared here
1922
- * with the final events flush and the last replay chunk, so an app emitting a
1923
- * span a second would silently lose its entire session row after ~30 minutes.
2131
+ * with the final events flush, a replay chunk and either SDK's OTLP
2132
+ * export, so an app emitting a span a second would silently lose its entire
2133
+ * session row after ~30 minutes.
1924
2134
  *
1925
2135
  * Keep-first rather than keep-last: the ids are a join key for "show me this
1926
2136
  * session's traces", the UI paginates them anyway, and dropping the tail of a
@@ -1977,4 +2187,4 @@ function readSessionSink() {
1977
2187
  return globalThis[SESSION_SINK_KEY];
1978
2188
  }
1979
2189
  //#endregion
1980
- export { setActiveTraceIdProvider as A, postSessionMeta as C, BLOCK_SELECTOR as D, selectorOf as E, scrubUrl as F, configureVisitorCookie as M, setVisitorTracking as N, safeEmit as O, addUrlSanitizer as P, postSessionBlob as S, warnDropped as T, rotateSession as _, recordTraceId as a, gzip as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, nextChunkSeq as g, markReplayTriggered as h, readSessionSink as i, withStartedTraceId as j, activeTraceId as k, onSessionEvent as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, postSessionMetaRow as v, sdkHint as w, ingestHeaders as x, isLikelyBot as y };
2190
+ export { selectorOf as A, mayPersistIdentifier as B, otlpKeepaliveRoom as C, reserveKeepalive as D, postToIngest as E, withStartedTraceId as F, scrubUrl as G, setConsent as H, configurePrivacy as I, mayUseStorage as K, consentAllowedSince as L, safeEmit as M, activeTraceId as N, sdkHint as O, setActiveTraceIdProvider as P, consentRevokedAt as R, ingestHeaders as S, postSessionMeta as T, setVisitorTracking as U, onConsentChange as V, isJsonObject as W, rotateSession as _, recordTraceId as a, OTLP_UNLOAD_TAIL_BYTES as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, nextChunkSeq as g, markReplayTriggered as h, readSessionSink as i, BLOCK_SELECTOR as j, warnDropped as k, onSessionEvent as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, postSessionMetaRow as v, postSessionBlob as w, gzip as x, isLikelyBot as y, hasConsent as z };
@@ -1,144 +1,13 @@
1
- import { A as setActiveTraceIdProvider, F as scrubUrl, M as configureVisitorCookie, N as setVisitorTracking, P as addUrlSanitizer, _ as rotateSession, a as recordTraceId, c as getActiveSink, d as startEventSink, f as claimReplayMode, i as readSessionSink, n as getObservedTraceIds, o as startSessionLifecycle, p as getSession, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as queuePending, v as postSessionMetaRow, w as sdkHint, x as ingestHeaders, y as isLikelyBot } from "./sink-BOR9_z4b.mjs";
1
+ import { B as mayPersistIdentifier, C as otlpKeepaliveRoom, E as postToIngest, G as scrubUrl, H as setConsent, I as configurePrivacy, L as consentAllowedSince, O as sdkHint, P as setActiveTraceIdProvider, S as ingestHeaders, U as setVisitorTracking, V as onConsentChange, _ as rotateSession, a as recordTraceId, b as OTLP_UNLOAD_TAIL_BYTES, c as getActiveSink, d as startEventSink, f as claimReplayMode, i as readSessionSink, n as getObservedTraceIds, o as startSessionLifecycle, p as getSession, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as queuePending, v as postSessionMetaRow, y as isLikelyBot, z as hasConsent } from "./sink-DbEuphC-.mjs";
2
2
  import { ProxyTracerProvider, SpanKind, SpanStatusCode, TraceFlags, context, createContextKey, createTraceState, isSpanContextValid, propagation, trace } from "@opentelemetry/api";
3
3
  import { BatchSpanProcessor, SamplingDecision } from "@opentelemetry/sdk-trace-base";
4
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
5
4
  import { registerInstrumentations } from "@opentelemetry/instrumentation";
6
5
  import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
7
6
  import { XMLHttpRequestInstrumentation } from "@opentelemetry/instrumentation-xml-http-request";
7
+ import { JsonTraceSerializer } from "@opentelemetry/otlp-transformer";
8
8
  import { resourceFromAttributes } from "@opentelemetry/resources";
9
9
  import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
10
10
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
11
- //#region ../browser-session/src/identity/consent.ts
12
- /**
13
- * Consent gating for session capture.
14
- *
15
- * The posture, deliberately:
16
- *
17
- * - `requireConsent` defaults to **false**. Flipping that default would
18
- * silently stop telemetry for every existing install — an availability
19
- * incident dressed up as a privacy win. Apps that need a consent gate opt in.
20
- * - Global Privacy Control **is** honored by default, but only against the
21
- * persistent visitor id, not the session itself. GPC is a legally recognized
22
- * signal in several US states and it maps exactly onto the one cross-session
23
- * identifier this SDK stores; the session-scoped capture that powers replay
24
- * and error triage is unaffected.
25
- * - `doNotTrack` is **not** honored by default. It is deprecated, removed from
26
- * Safari, and ignored across the industry, so respecting it would mostly mean
27
- * dropping data from users who never intended to opt out. Apps can turn it on.
28
- */
29
- /**
30
- * Consent lives on `globalThis`, for the same reason the event sink does
31
- * (`events/events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
32
- * Effect SDK gets **two copies** of this module, and per-copy state would mean
33
- * `setConsent(true)` on one never releases the other copy's exporters — or, the
34
- * other way round, one copy capturing while the other believes consent is
35
- * required. There is one user and one decision, so there is one state.
36
- */
37
- const CONSENT_KEY = "__MAPLE_BROWSER_CONSENT__";
38
- function consentState() {
39
- const owner = globalThis;
40
- const existing = owner[CONSENT_KEY];
41
- if (existing) return existing;
42
- const fresh = {
43
- allowedSince: 0,
44
- granted: false,
45
- listeners: /* @__PURE__ */ new Set(),
46
- requireConsent: false,
47
- respectDoNotTrack: false
48
- };
49
- owner[CONSENT_KEY] = fresh;
50
- return fresh;
51
- }
52
- /** When consent was last withdrawn, on any page of this origin. Persisted: the grant time resets every load. */
53
- const REVOKED_AT_KEY = "maple-consent-revoked-at";
54
- function writeRevokedAt(at) {
55
- try {
56
- localStorage.setItem(REVOKED_AT_KEY, String(at));
57
- } catch {}
58
- }
59
- /** Epoch ms of the last consent withdrawal on this origin, 0 if never. Data kept from before it must not be sent. */
60
- function consentRevokedAt() {
61
- try {
62
- return Number(localStorage.getItem(REVOKED_AT_KEY)) || 0;
63
- } catch {
64
- return 0;
65
- }
66
- }
67
- function updateEffectiveConsent(previous) {
68
- const state = consentState();
69
- const allowed = hasConsent();
70
- if (allowed === previous) return;
71
- state.allowedSince = allowed ? Date.now() : Number.POSITIVE_INFINITY;
72
- for (const listener of state.listeners) try {
73
- listener(allowed);
74
- } catch {}
75
- }
76
- /**
77
- * Apply the host app's privacy config. Call before anything captures.
78
- *
79
- * Both SDKs call this at startup, and an app can init one of them without a
80
- * `privacy` block — so the gates only ever **tighten**. Letting the second
81
- * caller's absent option reset `requireConsent` back to false would silently
82
- * disable a gate the first caller asked for, and the resulting
83
- * denied→allowed transition would start capture on a user who never consented.
84
- * To genuinely relax a gate, reload without it.
85
- */
86
- function configurePrivacy(options) {
87
- const state = consentState();
88
- const previous = hasConsent();
89
- state.requireConsent = state.requireConsent || (options?.requireConsent ?? false);
90
- state.respectDoNotTrack = state.respectDoNotTrack || (options?.respectDoNotTrack ?? false);
91
- configureVisitorCookie({
92
- crossSubdomainCookie: options?.crossSubdomainCookie,
93
- cookieDomain: options?.cookieDomain
94
- });
95
- if (options?.sanitizeUrl) addUrlSanitizer(options.sanitizeUrl);
96
- updateEffectiveConsent(previous);
97
- }
98
- /** Record the user's consent decision. No-op unless `requireConsent` is set. */
99
- function setConsent(nextGranted) {
100
- const previous = hasConsent();
101
- const state = consentState();
102
- if (state.granted && !nextGranted) writeRevokedAt(Date.now());
103
- state.granted = nextGranted;
104
- updateEffectiveConsent(previous);
105
- }
106
- /** Whether capture may proceed at all. */
107
- function hasConsent() {
108
- const state = consentState();
109
- return !state.requireConsent || state.granted;
110
- }
111
- /**
112
- * Subscribe to effective capture permission changes. The listener is invoked
113
- * only when capture actually transitions between allowed and denied; changing
114
- * the stored decision while `requireConsent` is off is deliberately silent.
115
- */
116
- function onConsentChange(listener) {
117
- const { listeners } = consentState();
118
- listeners.add(listener);
119
- return () => listeners.delete(listener);
120
- }
121
- /**
122
- * Earliest epoch-ms timestamp telemetry may export for the current grant.
123
- * `Infinity` means capture is denied. Exporters use this to discard anything
124
- * buffered before a late grant or across a revoke/re-grant cycle.
125
- */
126
- function consentAllowedSince() {
127
- return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
128
- }
129
- /**
130
- * Whether a *persistent* identifier may be stored. Separate from `hasConsent`
131
- * because GPC/DNT suppress the cross-session id without suppressing capture.
132
- */
133
- function mayPersistIdentifier() {
134
- if (!hasConsent()) return false;
135
- if (typeof navigator === "undefined") return true;
136
- const nav = navigator;
137
- if (nav.globalPrivacyControl === true) return false;
138
- if (consentState().respectDoNotTrack && nav.doNotTrack === "1") return false;
139
- return true;
140
- }
141
- //#endregion
142
11
  //#region ../browser-session/src/identity/identity.ts
143
12
  /**
144
13
  * Trait caps. The warehouse column is `Map(String, String)` — plain keys
@@ -228,6 +97,18 @@ function startMetadataSession(options) {
228
97
  });
229
98
  }
230
99
  //#endregion
100
+ //#region ../browser-session/src/session/pending-chunk.ts
101
+ const PENDING_CHUNK_KEY = "maple.replay.pending";
102
+ /** Remove the stored chunk. False when storage is blocked: nothing is known to be removed. */
103
+ function clearPendingChunk() {
104
+ try {
105
+ window.sessionStorage.removeItem(PENDING_CHUNK_KEY);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+ //#endregion
231
112
  //#region ../browser-session/src/events/props.ts
232
113
  const MAX_PROPS = 32;
233
114
  const MAX_PROP_KEY_LENGTH = 64;
@@ -542,6 +423,8 @@ function resolveHeaderCapture(raw) {
542
423
  }
543
424
  //#endregion
544
425
  //#region ../sdk-core/src/http-status.ts
426
+ /** The statuses the HTTP semantic conventions make a client span Error for: every 4xx and 5xx. */
427
+ const DEFAULT_ERROR_STATUS = [[400, 599]];
545
428
  const inStatusRanges = (status, ranges) => ranges.some((range) => typeof range === "number" ? range === status : status >= range[0] && status <= range[1]);
546
429
  /** The status on a span's attributes, current or pre-1.23 semconv key. */
547
430
  function responseStatus(read) {
@@ -550,17 +433,12 @@ function responseStatus(read) {
550
433
  if (typeof value === "string" && /^\d{3}$/.test(value)) return Number(value);
551
434
  }
552
435
  /**
553
- * The `error.type` / `error.message` pair for a listed status or a network
554
- * failure (`TypeError`, `timeout`), e.g. `POST https://api.example.com/users/42 -> 503`.
555
- * The query is dropped; ids in the path are redacted by the issue fingerprint.
436
+ * The `error.type` for a counted status or a network failure (`TypeError`,
437
+ * `timeout`), per the HTTP semantic conventions. No message: `error.message` is
438
+ * deprecated, and the status code already says what went wrong.
556
439
  */
557
- function httpStatusError(read, status) {
558
- const method = read("http.request.method") ?? read("http.method") ?? "GET";
559
- const url = String(read("url.full") ?? read("http.url") ?? "").replace(/[?#].*$/, "");
560
- return {
561
- "error.type": String(status),
562
- "error.message": `${String(method)} ${url} -> ${status}`
563
- };
440
+ function httpErrorType(status) {
441
+ return { "error.type": String(status) };
564
442
  }
565
443
  //#endregion
566
444
  //#region ../sdk-core/src/log-record.ts
@@ -842,7 +720,7 @@ function withStatus(span, status, attributes) {
842
720
  var HttpStatusExporter = class {
843
721
  inner;
844
722
  captureStatus;
845
- constructor(inner, captureStatus = []) {
723
+ constructor(inner, captureStatus = DEFAULT_ERROR_STATUS) {
846
724
  this.inner = inner;
847
725
  this.captureStatus = captureStatus;
848
726
  }
@@ -853,14 +731,9 @@ var HttpStatusExporter = class {
853
731
  if (span.events.some((event) => event.name === "exception")) return span;
854
732
  return withStatus(span, { code: SpanStatusCode.ERROR }, {
855
733
  ...span.attributes,
856
- ...httpStatusError(read, status)
734
+ ...httpErrorType(status)
857
735
  });
858
736
  }
859
- const errorType = span.attributes["error.type"];
860
- if (span.kind === SpanKind.CLIENT && span.status.code === SpanStatusCode.ERROR && typeof errorType === "string" && !HTTP_STATUS.test(errorType) && span.attributes["error.message"] === void 0 && !span.events.some((event) => event.name === "exception")) return withStatus(span, span.status, {
861
- ...span.attributes,
862
- "error.message": httpStatusError(read, errorType)["error.message"]
863
- });
864
737
  if (!isStatusOnlyError(span)) return span;
865
738
  const { "error.type": _errorType, ...attributes } = span.attributes;
866
739
  return withStatus(span, { code: SpanStatusCode.UNSET }, attributes);
@@ -913,8 +786,144 @@ var OfflineSpanExporter = class {
913
786
  }
914
787
  };
915
788
  //#endregion
789
+ //#region src/otlp.ts
790
+ const EXPORT_TIMEOUT_MS = 1e4;
791
+ const RETRYABLE_STATUS = [
792
+ 429,
793
+ 502,
794
+ 503,
795
+ 504
796
+ ];
797
+ /** Keepalive room the export being handed over must leave free; set for the older part of a split. */
798
+ let reserve = 0;
799
+ /**
800
+ * Send one body, retrying with a jittered backoff (1s, growing 1.5x) until the
801
+ * next wait would pass the deadline. The first request is issued before this
802
+ * returns, which an unload flush needs. Keepalive is asked for only while
803
+ * `leaveFree` bytes of OTLP's room would remain after the body.
804
+ */
805
+ async function send(url, headers, body, leaveFree) {
806
+ const deadline = Date.now() + EXPORT_TIMEOUT_MS;
807
+ let timeoutMs = EXPORT_TIMEOUT_MS;
808
+ let backoff = 1e3;
809
+ for (;;) {
810
+ const controller = new AbortController();
811
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
812
+ const keepalive = body.byteLength + leaveFree <= otlpKeepaliveRoom();
813
+ const failure = await postToIngest(url, headers, body, keepalive, {
814
+ signal: controller.signal,
815
+ otlp: true
816
+ }).then((response) => response.ok ? void 0 : {
817
+ error: /* @__PURE__ */ new Error(`OTLP export failed: ${response.status}`),
818
+ retryable: RETRYABLE_STATUS.includes(response.status)
819
+ }, (cause) => ({
820
+ error: new Error("OTLP export failed", { cause }),
821
+ retryable: cause instanceof TypeError
822
+ })).finally(() => clearTimeout(timer));
823
+ if (!failure) return { code: 0 };
824
+ const wait = backoff * (.8 + Math.random() * .4);
825
+ timeoutMs = deadline - Date.now();
826
+ if (!failure.retryable || wait > timeoutMs) return {
827
+ code: 1,
828
+ error: failure.error
829
+ };
830
+ backoff *= 1.5;
831
+ await new Promise((resolve) => setTimeout(resolve, wait));
832
+ }
833
+ }
834
+ /** Exports each batch as one OTLP/JSON request to `url`. */
835
+ var OtlpExporter = class {
836
+ url;
837
+ headers;
838
+ serializer;
839
+ inflight = /* @__PURE__ */ new Set();
840
+ constructor(url, headers, serializer) {
841
+ this.url = url;
842
+ this.headers = headers;
843
+ this.serializer = serializer;
844
+ }
845
+ export(items, callback) {
846
+ const body = this.serializer.serializeRequest(items);
847
+ if (!body) {
848
+ callback({ code: 1 });
849
+ return;
850
+ }
851
+ const headers = {
852
+ ...this.headers,
853
+ "content-type": "application/json"
854
+ };
855
+ const sent = send(this.url, headers, body, reserve).then(callback).catch(() => {});
856
+ this.inflight.add(sent);
857
+ sent.finally(() => this.inflight.delete(sent));
858
+ }
859
+ /** Resolves once every export in flight has reported its result. */
860
+ async forceFlush() {
861
+ await Promise.all(this.inflight);
862
+ }
863
+ shutdown() {
864
+ return this.forceFlush();
865
+ }
866
+ };
867
+ let unloading = false;
868
+ /**
869
+ * Run a `pagehide` flush. The document still reads as visible there, so the
870
+ * flush says it is leaving; `flush` must issue its exports before it returns.
871
+ */
872
+ function flushUnloading(flush) {
873
+ unloading = true;
874
+ try {
875
+ flush();
876
+ } finally {
877
+ unloading = false;
878
+ }
879
+ }
880
+ /**
881
+ * The newest items whose body fits what keepalive has room for right now, at
882
+ * most `OTLP_UNLOAD_TAIL_BYTES`, then everything older. No split when nothing
883
+ * or everything fits.
884
+ */
885
+ function newestFirst(items, serializer) {
886
+ const limit = Math.min(OTLP_UNLOAD_TAIL_BYTES, otlpKeepaliveRoom());
887
+ const size = (from) => serializer.serializeRequest(items.slice(from))?.byteLength ?? 0;
888
+ let start = items.length;
889
+ while (start > 0 && size(start - 1) <= limit) start -= 1;
890
+ return start > 0 && start < items.length ? [items.slice(start), items.slice(0, start)] : [items];
891
+ }
892
+ /**
893
+ * A hidden or unloading document may not live to see a response, and a plain
894
+ * request is terminated with it. There each batch becomes two exports: its
895
+ * newest items first, in a body small enough for keepalive, then the rest,
896
+ * which keeps keepalive while it fits and leaves `olderReserve` bytes of room
897
+ * for what is flushed after it. Each goes through `inner` on its own, so only
898
+ * the part that failed is queued.
899
+ */
900
+ function newestFirstOnExit(inner, serializer, olderReserve = 0) {
901
+ return {
902
+ export(items, callback) {
903
+ const hidden = globalThis.document?.visibilityState === "hidden";
904
+ const parts = unloading || hidden ? newestFirst(items, serializer) : [items];
905
+ let pending = parts.length;
906
+ let failed;
907
+ for (const [index, part] of parts.entries()) {
908
+ reserve = index > 0 ? olderReserve : 0;
909
+ try {
910
+ inner.export(part, (result) => {
911
+ if (result.code !== 0) failed = result;
912
+ pending -= 1;
913
+ if (pending === 0) callback(failed ?? result);
914
+ });
915
+ } finally {
916
+ reserve = 0;
917
+ }
918
+ }
919
+ },
920
+ forceFlush: () => inner.forceFlush(),
921
+ shutdown: () => inner.shutdown()
922
+ };
923
+ }
924
+ //#endregion
916
925
  //#region src/version.ts
917
- const SDK_VERSION = "0.9.0";
926
+ const SDK_VERSION = "0.10.1";
918
927
  /** The `x-maple-sdk` value this build sends. */
919
928
  const SDK_NAME = "maple-browser";
920
929
  //#endregion
@@ -990,6 +999,8 @@ var ConsentSpanExporter = class {
990
999
  * requests for a materially smaller loss window.
991
1000
  */
992
1001
  const EXPORT_INTERVAL_MS = 2e3;
1002
+ /** Keepalive room the older spans of an exit batch leave for the logs, which are flushed after them. */
1003
+ const EXIT_LOGS_RESERVE_BYTES = 8192;
993
1004
  /**
994
1005
  * The provider this SDK registered, while it is live. Everything Maple spans
995
1006
  * goes through it directly: the global provider may belong to the host app,
@@ -1032,14 +1043,11 @@ function resourceAttributes(config) {
1032
1043
  * live id per span instead.
1033
1044
  */
1034
1045
  function setupTracing(config) {
1035
- const otlp = new OTLPTraceExporter({
1036
- url: `${config.endpoint}/v1/traces`,
1037
- headers: ingestHeaders({
1038
- ingestKey: config.ingestKey,
1039
- sdk: sdkHint(SDK_NAME, SDK_VERSION)
1040
- })
1041
- });
1042
- const exporter = new ConsentSpanExporter(new HttpStatusExporter(config.offlineQueue ? new OfflineSpanExporter(otlp) : otlp, config.errorFilters.captureHttpStatus));
1046
+ const otlp = new OtlpExporter(`${config.endpoint}/v1/traces`, ingestHeaders({
1047
+ ingestKey: config.ingestKey,
1048
+ sdk: sdkHint(SDK_NAME, SDK_VERSION)
1049
+ }), JsonTraceSerializer);
1050
+ const exporter = new ConsentSpanExporter(new HttpStatusExporter(newestFirstOnExit(config.offlineQueue ? new OfflineSpanExporter(otlp) : otlp, JsonTraceSerializer, EXIT_LOGS_RESERVE_BYTES), config.errorFilters.captureHttpStatus));
1043
1051
  const provider = new WebTracerProvider({
1044
1052
  resource: resourceFromAttributes(resourceAttributes(config)),
1045
1053
  sampler: new SessionSampler(config.tracingSampleRate),
@@ -1056,11 +1064,11 @@ function setupTracing(config) {
1056
1064
  if (document.visibilityState === "hidden") onExit();
1057
1065
  };
1058
1066
  const settledRequests = /* @__PURE__ */ new Map();
1059
- const onPageHide = () => {
1067
+ const onPageHide = () => flushUnloading(() => {
1060
1068
  for (const [span, endTime] of settledRequests) if (span.isRecording()) span.end(endTime);
1061
1069
  settledRequests.clear();
1062
1070
  onExit();
1063
- };
1071
+ });
1064
1072
  const noteSettled = (span) => {
1065
1073
  for (const settled of settledRequests.keys()) if (!settled.isRecording()) settledRequests.delete(settled);
1066
1074
  settledRequests.set(span, Date.now());
@@ -1082,7 +1090,7 @@ function setupTracing(config) {
1082
1090
  const sent = request instanceof Request ? request.headers : new Headers(request.headers);
1083
1091
  setHeaderAttributes(span, "request", headers.request, (name) => sent.get(name));
1084
1092
  if (result instanceof Response) setHeaderAttributes(span, "response", headers.response, (name) => result.headers.get(name));
1085
- else if (result instanceof Error && result.name !== "AbortError" && !request.signal?.aborted) {
1093
+ else if (result instanceof Error && (isTimeout(result, request.signal) || result.name !== "AbortError" && !request.signal?.aborted)) {
1086
1094
  span.setStatus({
1087
1095
  code: SpanStatusCode.ERROR,
1088
1096
  message: result.message
@@ -1118,6 +1126,11 @@ function setupTracing(config) {
1118
1126
  }
1119
1127
  };
1120
1128
  }
1129
+ /** `AbortSignal.timeout()` aborts with, and rejects the fetch with, a `TimeoutError` DOMException. */
1130
+ function isTimeout(error, signal) {
1131
+ const reason = signal?.reason;
1132
+ return error.name === "TimeoutError" || reason instanceof DOMException && reason.name === "TimeoutError";
1133
+ }
1121
1134
  function escapeRegExp(value) {
1122
1135
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1123
1136
  }
@@ -1268,6 +1281,7 @@ function resolveConfig(config) {
1268
1281
  maskAllInputs: config.privacy?.maskAllInputs ?? true,
1269
1282
  maskAllText: config.privacy?.maskAllText ?? false,
1270
1283
  persistVisitorId: config.privacy?.persistVisitorId ?? true,
1284
+ persistence: config.privacy?.persistence ?? "browser",
1271
1285
  crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
1272
1286
  cookieDomain: config.privacy?.cookieDomain,
1273
1287
  requireConsent: config.privacy?.requireConsent ?? false,
@@ -1533,7 +1547,7 @@ function init(rawConfig) {
1533
1547
  if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) stopErrorCapture = setupErrorCapture();
1534
1548
  if (!deferredPending) {
1535
1549
  setLogIdentity(() => activeConfig?.identity?.id);
1536
- deferredPending = import("./deferred-CdtlxG0Q.mjs").then(({ startDeferred }) => {
1550
+ deferredPending = import("./deferred-Bv2SmnTY.mjs").then(({ startDeferred }) => {
1537
1551
  stopDeferred = startDeferred(config);
1538
1552
  }).catch(() => {});
1539
1553
  }
@@ -1567,7 +1581,7 @@ function init(rawConfig) {
1567
1581
  runtime = next;
1568
1582
  const ownGeneration = ++generation;
1569
1583
  const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
1570
- next.replayPending = import("./replay-session-DdVYdHc_.mjs").then(({ startReplaySession }) => {
1584
+ next.replayPending = import("./replay-session-2uaCw3AU.mjs").then(({ startReplaySession }) => {
1571
1585
  if (stale()) return;
1572
1586
  next.replay = startReplaySession({
1573
1587
  ...shared,
@@ -1613,6 +1627,7 @@ function init(rawConfig) {
1613
1627
  }
1614
1628
  rotateOnNextStart = runtime !== void 0;
1615
1629
  clearPendingEvents();
1630
+ clearPendingChunk();
1616
1631
  setVisitorTracking(false);
1617
1632
  stopRuntime(false);
1618
1633
  }) : () => {};
@@ -1682,6 +1697,12 @@ const method = (level) => (message, attributes) => {
1682
1697
  });
1683
1698
  } catch {}
1684
1699
  };
1700
+ const logger = {
1701
+ debug: method("DEBUG"),
1702
+ info: method("INFO"),
1703
+ warn: method("WARN"),
1704
+ error: method("ERROR")
1705
+ };
1685
1706
  //#endregion
1686
1707
  //#region src/index.ts
1687
1708
  /**
@@ -1711,12 +1732,7 @@ const MapleBrowser = {
1711
1732
  startNavigation,
1712
1733
  endNavigation,
1713
1734
  traced,
1714
- logger: {
1715
- debug: method("DEBUG"),
1716
- info: method("INFO"),
1717
- warn: method("WARN"),
1718
- error: method("ERROR")
1719
- }
1735
+ logger
1720
1736
  };
1721
1737
  //#endregion
1722
- export { consentRevokedAt as _, detachLogSink as a, resourceAttributes as c, attachSpanStash as d, claimPageSignal as f, consentAllowedSince as g, severityOf as h, attachLogSink as i, SDK_NAME as l, Severity as m, navigationSpanAt as n, emitLog as o, onErrorRecorded as p, onDocumentPageload as r, liveMapleTracer as s, MapleBrowser as t, SDK_VERSION as u, hasConsent as v, onConsentChange as y };
1738
+ export { severityOf as _, detachLogSink as a, resourceAttributes as c, OtlpExporter as d, newestFirstOnExit as f, Severity as g, onErrorRecorded as h, attachLogSink as i, SDK_NAME as l, claimPageSignal as m, navigationSpanAt as n, emitLog as o, attachSpanStash as p, onDocumentPageload as r, liveMapleTracer as s, MapleBrowser as t, SDK_VERSION as u, PENDING_CHUNK_KEY as v, clearPendingChunk as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maple-dev/browser",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -41,14 +41,12 @@
41
41
  },
42
42
  "scripts": {
43
43
  "build": "tsdown",
44
- "typecheck": "tsc --noEmit",
44
+ "typecheck": "tsrs --noEmit",
45
45
  "test": "vitest run",
46
46
  "size": "bun scripts/size.ts"
47
47
  },
48
48
  "dependencies": {
49
49
  "@opentelemetry/api": "^1.9.0",
50
- "@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
51
- "@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
52
50
  "@opentelemetry/instrumentation": "^0.222.0",
53
51
  "@opentelemetry/instrumentation-fetch": "^0.222.0",
54
52
  "@opentelemetry/instrumentation-xml-http-request": "^0.222.0",
@@ -66,13 +64,13 @@
66
64
  "@maple/sdk-core": "0.1.0",
67
65
  "@types/react": "^19.3.0",
68
66
  "@types/react-dom": "^19.3.0",
69
- "@vitest/browser-playwright": "^5.0.2",
67
+ "@vitest/browser-playwright": "^5.0.3",
70
68
  "playwright": "^1.63.0",
71
69
  "react": "^19.3.0",
72
70
  "react-dom": "^19.3.0",
73
71
  "tsdown": "^0.23.0",
74
72
  "typescript": "^7.0.2",
75
- "vitest": "^5.0.2"
73
+ "vitest": "^5.0.3"
76
74
  },
77
75
  "peerDependencies": {
78
76
  "react": ">=18"
@@ -82,4 +80,4 @@
82
80
  "optional": true
83
81
  }
84
82
  }
85
- }
83
+ }