@maple-dev/browser 0.10.1 → 0.11.1

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.
@@ -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-D4e1o0x4.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-CCfykEMo.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.
@@ -233,6 +236,13 @@ interface MapleBrowserConfig extends SignalOptions {
233
236
  * purges any id already stored.
234
237
  */
235
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;
236
246
  /**
237
247
  * Scope the visitor-id cookie to the registered domain, so a marketing site
238
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-D4e1o0x4.mjs";
1
+ import { t as MapleBrowser } from "./src-CCfykEMo.mjs";
2
2
  export { MapleBrowser };
package/dist/react.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as MapleBrowser } from "./src-D4e1o0x4.mjs";
1
+ import { t as MapleBrowser } from "./src-CCfykEMo.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-CCfykEMo.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;
@@ -905,8 +786,144 @@ var OfflineSpanExporter = class {
905
786
  }
906
787
  };
907
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
908
925
  //#region src/version.ts
909
- const SDK_VERSION = "0.10.1";
926
+ const SDK_VERSION = "0.11.0";
910
927
  /** The `x-maple-sdk` value this build sends. */
911
928
  const SDK_NAME = "maple-browser";
912
929
  //#endregion
@@ -982,6 +999,8 @@ var ConsentSpanExporter = class {
982
999
  * requests for a materially smaller loss window.
983
1000
  */
984
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;
985
1004
  /**
986
1005
  * The provider this SDK registered, while it is live. Everything Maple spans
987
1006
  * goes through it directly: the global provider may belong to the host app,
@@ -1024,14 +1043,11 @@ function resourceAttributes(config) {
1024
1043
  * live id per span instead.
1025
1044
  */
1026
1045
  function setupTracing(config) {
1027
- const otlp = new OTLPTraceExporter({
1028
- url: `${config.endpoint}/v1/traces`,
1029
- headers: ingestHeaders({
1030
- ingestKey: config.ingestKey,
1031
- sdk: sdkHint(SDK_NAME, SDK_VERSION)
1032
- })
1033
- });
1034
- 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));
1035
1051
  const provider = new WebTracerProvider({
1036
1052
  resource: resourceFromAttributes(resourceAttributes(config)),
1037
1053
  sampler: new SessionSampler(config.tracingSampleRate),
@@ -1044,14 +1060,33 @@ function setupTracing(config) {
1044
1060
  const onExit = () => {
1045
1061
  provider.forceFlush().catch(() => {});
1046
1062
  };
1063
+ let connectionLosses = 0;
1064
+ let lastLossReason = "";
1065
+ const lossesAtStart = /* @__PURE__ */ new WeakMap();
1066
+ const noteLost = (reason) => {
1067
+ connectionLosses += 1;
1068
+ lastLossReason = reason;
1069
+ };
1070
+ const connectionLost = (span) => {
1071
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return "offline";
1072
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return "page_hidden";
1073
+ const atStart = lossesAtStart.get(span);
1074
+ return atStart !== void 0 && atStart !== connectionLosses ? lastLossReason : void 0;
1075
+ };
1076
+ const onOffline = () => noteLost("offline");
1047
1077
  const onVisibilityChange = () => {
1048
- if (document.visibilityState === "hidden") onExit();
1078
+ if (document.visibilityState !== "hidden") return;
1079
+ noteLost("page_hidden");
1080
+ onExit();
1049
1081
  };
1050
1082
  const settledRequests = /* @__PURE__ */ new Map();
1051
1083
  const onPageHide = () => {
1052
- for (const [span, endTime] of settledRequests) if (span.isRecording()) span.end(endTime);
1053
- settledRequests.clear();
1054
- onExit();
1084
+ noteLost("page_unload");
1085
+ flushUnloading(() => {
1086
+ for (const [span, endTime] of settledRequests) if (span.isRecording()) span.end(endTime);
1087
+ settledRequests.clear();
1088
+ onExit();
1089
+ });
1055
1090
  };
1056
1091
  const noteSettled = (span) => {
1057
1092
  for (const settled of settledRequests.keys()) if (!settled.isRecording()) settledRequests.delete(settled);
@@ -1061,6 +1096,7 @@ function setupTracing(config) {
1061
1096
  if (canListen) {
1062
1097
  document.addEventListener("visibilitychange", onVisibilityChange);
1063
1098
  window.addEventListener("pagehide", onPageHide);
1099
+ window.addEventListener("offline", onOffline);
1064
1100
  }
1065
1101
  const requestOptions = {
1066
1102
  ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)],
@@ -1069,17 +1105,24 @@ function setupTracing(config) {
1069
1105
  const headers = config.captureHeaders;
1070
1106
  const instrumentations = [...config.tracingInstrumentFetch ? [new FetchInstrumentation({
1071
1107
  ...requestOptions,
1108
+ requestHook: (span) => {
1109
+ lossesAtStart.set(span, connectionLosses);
1110
+ },
1072
1111
  applyCustomAttributesOnSpan: (span, request, result) => {
1073
1112
  noteSettled(span);
1074
1113
  const sent = request instanceof Request ? request.headers : new Headers(request.headers);
1075
1114
  setHeaderAttributes(span, "request", headers.request, (name) => sent.get(name));
1076
1115
  if (result instanceof Response) setHeaderAttributes(span, "response", headers.response, (name) => result.headers.get(name));
1077
1116
  else if (result instanceof Error && (isTimeout(result, request.signal) || result.name !== "AbortError" && !request.signal?.aborted)) {
1078
- span.setStatus({
1079
- code: SpanStatusCode.ERROR,
1080
- message: result.message
1081
- });
1082
- span.setAttribute("error.type", result.name);
1117
+ const lost = connectionLost(span);
1118
+ if (lost !== void 0) span.setAttribute("maple.connection_lost", lost);
1119
+ else {
1120
+ span.setStatus({
1121
+ code: SpanStatusCode.ERROR,
1122
+ message: result.message
1123
+ });
1124
+ span.setAttribute("error.type", result.name);
1125
+ }
1083
1126
  }
1084
1127
  }
1085
1128
  })] : [], ...config.tracingInstrumentXhr ? [new XMLHttpRequestInstrumentation({
@@ -1099,6 +1142,7 @@ function setupTracing(config) {
1099
1142
  if (canListen) {
1100
1143
  document.removeEventListener("visibilitychange", onVisibilityChange);
1101
1144
  window.removeEventListener("pagehide", onPageHide);
1145
+ window.removeEventListener("offline", onOffline);
1102
1146
  }
1103
1147
  unregisterInstrumentations?.();
1104
1148
  if (mapleProvider === provider) mapleProvider = void 0;
@@ -1265,6 +1309,7 @@ function resolveConfig(config) {
1265
1309
  maskAllInputs: config.privacy?.maskAllInputs ?? true,
1266
1310
  maskAllText: config.privacy?.maskAllText ?? false,
1267
1311
  persistVisitorId: config.privacy?.persistVisitorId ?? true,
1312
+ persistence: config.privacy?.persistence ?? "browser",
1268
1313
  crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
1269
1314
  cookieDomain: config.privacy?.cookieDomain,
1270
1315
  requireConsent: config.privacy?.requireConsent ?? false,
@@ -1530,7 +1575,7 @@ function init(rawConfig) {
1530
1575
  if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) stopErrorCapture = setupErrorCapture();
1531
1576
  if (!deferredPending) {
1532
1577
  setLogIdentity(() => activeConfig?.identity?.id);
1533
- deferredPending = import("./deferred-BDp5NC7U.mjs").then(({ startDeferred }) => {
1578
+ deferredPending = import("./deferred-B8pFPgZ_.mjs").then(({ startDeferred }) => {
1534
1579
  stopDeferred = startDeferred(config);
1535
1580
  }).catch(() => {});
1536
1581
  }
@@ -1564,7 +1609,7 @@ function init(rawConfig) {
1564
1609
  runtime = next;
1565
1610
  const ownGeneration = ++generation;
1566
1611
  const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
1567
- next.replayPending = import("./replay-session-DdVYdHc_.mjs").then(({ startReplaySession }) => {
1612
+ next.replayPending = import("./replay-session-D0zjvZIV.mjs").then(({ startReplaySession }) => {
1568
1613
  if (stale()) return;
1569
1614
  next.replay = startReplaySession({
1570
1615
  ...shared,
@@ -1610,6 +1655,7 @@ function init(rawConfig) {
1610
1655
  }
1611
1656
  rotateOnNextStart = runtime !== void 0;
1612
1657
  clearPendingEvents();
1658
+ clearPendingChunk();
1613
1659
  setVisitorTracking(false);
1614
1660
  stopRuntime(false);
1615
1661
  }) : () => {};
@@ -1679,6 +1725,12 @@ const method = (level) => (message, attributes) => {
1679
1725
  });
1680
1726
  } catch {}
1681
1727
  };
1728
+ const logger = {
1729
+ debug: method("DEBUG"),
1730
+ info: method("INFO"),
1731
+ warn: method("WARN"),
1732
+ error: method("ERROR")
1733
+ };
1682
1734
  //#endregion
1683
1735
  //#region src/index.ts
1684
1736
  /**
@@ -1708,12 +1760,7 @@ const MapleBrowser = {
1708
1760
  startNavigation,
1709
1761
  endNavigation,
1710
1762
  traced,
1711
- logger: {
1712
- debug: method("DEBUG"),
1713
- info: method("INFO"),
1714
- warn: method("WARN"),
1715
- error: method("ERROR")
1716
- }
1763
+ logger
1717
1764
  };
1718
1765
  //#endregion
1719
- 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 };
1766
+ 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.1",
3
+ "version": "0.11.1",
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"