@maple-dev/browser 0.10.1 → 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/dist/{console-BrjBnOiX.mjs → console-Bkd3hBGp.mjs} +1 -1
- package/dist/{deferred-BDp5NC7U.mjs → deferred-Bv2SmnTY.mjs} +14 -13
- package/dist/index.d.mts +10 -0
- package/dist/index.mjs +1 -1
- package/dist/react.mjs +1 -1
- package/dist/{replay-session-DdVYdHc_.mjs → replay-session-2uaCw3AU.mjs} +79 -10
- package/dist/{sink-BOR9_z4b.mjs → sink-DbEuphC-.mjs} +254 -44
- package/dist/{src-D4e1o0x4.mjs → src-BiHeZJ9l.mjs} +171 -152
- package/package.json +4 -6
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { _ as
|
|
3
|
-
import { t as installConsoleCapture } from "./console-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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-
|
|
1
|
+
import { t as MapleBrowser } from "./src-BiHeZJ9l.mjs";
|
|
2
2
|
export { MapleBrowser };
|
package/dist/react.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
|
|
218
|
-
const
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
147
|
-
setRawCookie(name, "", domain, 0);
|
|
148
|
-
if (
|
|
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"
|
|
162
|
-
const
|
|
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
|
|
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
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* replay chunk), so the budget has
|
|
688
|
-
*
|
|
689
|
-
*
|
|
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
|
-
*
|
|
692
|
-
*
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
731
|
-
* until headers arrive. Rejects exactly as `fetch` does
|
|
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
|
-
/**
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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())}
|
|
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
|
-
|
|
1542
|
-
const
|
|
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
|
-
|
|
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
|
|
1923
|
-
* span a second would silently lose its entire
|
|
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 {
|
|
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 {
|
|
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,6 +786,142 @@ 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
926
|
const SDK_VERSION = "0.10.1";
|
|
910
927
|
/** The `x-maple-sdk` value this build sends. */
|
|
@@ -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
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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),
|
|
@@ -1048,11 +1064,11 @@ function setupTracing(config) {
|
|
|
1048
1064
|
if (document.visibilityState === "hidden") onExit();
|
|
1049
1065
|
};
|
|
1050
1066
|
const settledRequests = /* @__PURE__ */ new Map();
|
|
1051
|
-
const onPageHide = () => {
|
|
1067
|
+
const onPageHide = () => flushUnloading(() => {
|
|
1052
1068
|
for (const [span, endTime] of settledRequests) if (span.isRecording()) span.end(endTime);
|
|
1053
1069
|
settledRequests.clear();
|
|
1054
1070
|
onExit();
|
|
1055
|
-
};
|
|
1071
|
+
});
|
|
1056
1072
|
const noteSettled = (span) => {
|
|
1057
1073
|
for (const settled of settledRequests.keys()) if (!settled.isRecording()) settledRequests.delete(settled);
|
|
1058
1074
|
settledRequests.set(span, Date.now());
|
|
@@ -1265,6 +1281,7 @@ function resolveConfig(config) {
|
|
|
1265
1281
|
maskAllInputs: config.privacy?.maskAllInputs ?? true,
|
|
1266
1282
|
maskAllText: config.privacy?.maskAllText ?? false,
|
|
1267
1283
|
persistVisitorId: config.privacy?.persistVisitorId ?? true,
|
|
1284
|
+
persistence: config.privacy?.persistence ?? "browser",
|
|
1268
1285
|
crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
|
|
1269
1286
|
cookieDomain: config.privacy?.cookieDomain,
|
|
1270
1287
|
requireConsent: config.privacy?.requireConsent ?? false,
|
|
@@ -1530,7 +1547,7 @@ function init(rawConfig) {
|
|
|
1530
1547
|
if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) stopErrorCapture = setupErrorCapture();
|
|
1531
1548
|
if (!deferredPending) {
|
|
1532
1549
|
setLogIdentity(() => activeConfig?.identity?.id);
|
|
1533
|
-
deferredPending = import("./deferred-
|
|
1550
|
+
deferredPending = import("./deferred-Bv2SmnTY.mjs").then(({ startDeferred }) => {
|
|
1534
1551
|
stopDeferred = startDeferred(config);
|
|
1535
1552
|
}).catch(() => {});
|
|
1536
1553
|
}
|
|
@@ -1564,7 +1581,7 @@ function init(rawConfig) {
|
|
|
1564
1581
|
runtime = next;
|
|
1565
1582
|
const ownGeneration = ++generation;
|
|
1566
1583
|
const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
|
|
1567
|
-
next.replayPending = import("./replay-session-
|
|
1584
|
+
next.replayPending = import("./replay-session-2uaCw3AU.mjs").then(({ startReplaySession }) => {
|
|
1568
1585
|
if (stale()) return;
|
|
1569
1586
|
next.replay = startReplaySession({
|
|
1570
1587
|
...shared,
|
|
@@ -1610,6 +1627,7 @@ function init(rawConfig) {
|
|
|
1610
1627
|
}
|
|
1611
1628
|
rotateOnNextStart = runtime !== void 0;
|
|
1612
1629
|
clearPendingEvents();
|
|
1630
|
+
clearPendingChunk();
|
|
1613
1631
|
setVisitorTracking(false);
|
|
1614
1632
|
stopRuntime(false);
|
|
1615
1633
|
}) : () => {};
|
|
@@ -1679,6 +1697,12 @@ const method = (level) => (message, attributes) => {
|
|
|
1679
1697
|
});
|
|
1680
1698
|
} catch {}
|
|
1681
1699
|
};
|
|
1700
|
+
const logger = {
|
|
1701
|
+
debug: method("DEBUG"),
|
|
1702
|
+
info: method("INFO"),
|
|
1703
|
+
warn: method("WARN"),
|
|
1704
|
+
error: method("ERROR")
|
|
1705
|
+
};
|
|
1682
1706
|
//#endregion
|
|
1683
1707
|
//#region src/index.ts
|
|
1684
1708
|
/**
|
|
@@ -1708,12 +1732,7 @@ const MapleBrowser = {
|
|
|
1708
1732
|
startNavigation,
|
|
1709
1733
|
endNavigation,
|
|
1710
1734
|
traced,
|
|
1711
|
-
logger
|
|
1712
|
-
debug: method("DEBUG"),
|
|
1713
|
-
info: method("INFO"),
|
|
1714
|
-
warn: method("WARN"),
|
|
1715
|
-
error: method("ERROR")
|
|
1716
|
-
}
|
|
1735
|
+
logger
|
|
1717
1736
|
};
|
|
1718
1737
|
//#endregion
|
|
1719
|
-
export {
|
|
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.
|
|
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": "
|
|
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.
|
|
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.
|
|
73
|
+
"vitest": "^5.0.3"
|
|
76
74
|
},
|
|
77
75
|
"peerDependencies": {
|
|
78
76
|
"react": ">=18"
|