@maple-dev/browser 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -3
- package/dist/console-BrjBnOiX.mjs +73 -0
- package/dist/deferred-CdtlxG0Q.mjs +723 -0
- package/dist/index.d.mts +233 -30
- package/dist/index.mjs +1 -640
- package/dist/react.d.mts +88 -0
- package/dist/react.mjs +130 -0
- package/dist/replay-session-DdVYdHc_.mjs +527 -0
- package/dist/{sink-D9w1kg0Q.mjs → sink-BOR9_z4b.mjs} +733 -177
- package/dist/src-Bzmp8rVr.mjs +1722 -0
- package/package.json +37 -14
- package/dist/replay-session-BYZfi3iZ.mjs +0 -327
package/dist/index.mjs
CHANGED
|
@@ -1,641 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { trace } from "@opentelemetry/api";
|
|
3
|
-
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
4
|
-
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
|
5
|
-
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
|
|
6
|
-
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
7
|
-
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
8
|
-
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
|
|
9
|
-
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
10
|
-
//#region ../browser-session/src/identity/consent.ts
|
|
11
|
-
/**
|
|
12
|
-
* Consent gating for session capture.
|
|
13
|
-
*
|
|
14
|
-
* The posture, deliberately:
|
|
15
|
-
*
|
|
16
|
-
* - `requireConsent` defaults to **false**. Flipping that default would
|
|
17
|
-
* silently stop telemetry for every existing install — an availability
|
|
18
|
-
* incident dressed up as a privacy win. Apps that need a consent gate opt in.
|
|
19
|
-
* - Global Privacy Control **is** honored by default, but only against the
|
|
20
|
-
* persistent visitor id, not the session itself. GPC is a legally recognized
|
|
21
|
-
* signal in several US states and it maps exactly onto the one cross-session
|
|
22
|
-
* identifier this SDK stores; the session-scoped capture that powers replay
|
|
23
|
-
* and error triage is unaffected.
|
|
24
|
-
* - `doNotTrack` is **not** honored by default. It is deprecated, removed from
|
|
25
|
-
* Safari, and ignored across the industry, so respecting it would mostly mean
|
|
26
|
-
* dropping data from users who never intended to opt out. Apps can turn it on.
|
|
27
|
-
*/
|
|
28
|
-
/**
|
|
29
|
-
* Consent lives on `globalThis`, for the same reason the event sink does
|
|
30
|
-
* (`events/events-sink.ts`): an app that bundles both `@maple-dev/browser` and the
|
|
31
|
-
* Effect SDK gets **two copies** of this module, and per-copy state would mean
|
|
32
|
-
* `setConsent(true)` on one never releases the other copy's exporters — or, the
|
|
33
|
-
* other way round, one copy capturing while the other believes consent is
|
|
34
|
-
* required. There is one user and one decision, so there is one state.
|
|
35
|
-
*/
|
|
36
|
-
const CONSENT_KEY = "__MAPLE_BROWSER_CONSENT__";
|
|
37
|
-
function consentState() {
|
|
38
|
-
const owner = globalThis;
|
|
39
|
-
const existing = owner[CONSENT_KEY];
|
|
40
|
-
if (existing) return existing;
|
|
41
|
-
const fresh = {
|
|
42
|
-
allowedSince: 0,
|
|
43
|
-
granted: false,
|
|
44
|
-
listeners: /* @__PURE__ */ new Set(),
|
|
45
|
-
requireConsent: false,
|
|
46
|
-
respectDoNotTrack: false
|
|
47
|
-
};
|
|
48
|
-
owner[CONSENT_KEY] = fresh;
|
|
49
|
-
return fresh;
|
|
50
|
-
}
|
|
51
|
-
function updateEffectiveConsent(previous) {
|
|
52
|
-
const state = consentState();
|
|
53
|
-
const allowed = hasConsent();
|
|
54
|
-
if (allowed === previous) return;
|
|
55
|
-
state.allowedSince = allowed ? Date.now() : Number.POSITIVE_INFINITY;
|
|
56
|
-
for (const listener of state.listeners) try {
|
|
57
|
-
listener(allowed);
|
|
58
|
-
} catch {}
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Apply the host app's privacy config. Call before anything captures.
|
|
62
|
-
*
|
|
63
|
-
* Both SDKs call this at startup, and an app can init one of them without a
|
|
64
|
-
* `privacy` block — so the gates only ever **tighten**. Letting the second
|
|
65
|
-
* caller's absent option reset `requireConsent` back to false would silently
|
|
66
|
-
* disable a gate the first caller asked for, and the resulting
|
|
67
|
-
* denied→allowed transition would start capture on a user who never consented.
|
|
68
|
-
* To genuinely relax a gate, reload without it.
|
|
69
|
-
*/
|
|
70
|
-
function configurePrivacy(options) {
|
|
71
|
-
const state = consentState();
|
|
72
|
-
const previous = hasConsent();
|
|
73
|
-
state.requireConsent = state.requireConsent || (options?.requireConsent ?? false);
|
|
74
|
-
state.respectDoNotTrack = state.respectDoNotTrack || (options?.respectDoNotTrack ?? false);
|
|
75
|
-
configureVisitorCookie({
|
|
76
|
-
crossSubdomainCookie: options?.crossSubdomainCookie,
|
|
77
|
-
cookieDomain: options?.cookieDomain
|
|
78
|
-
});
|
|
79
|
-
updateEffectiveConsent(previous);
|
|
80
|
-
}
|
|
81
|
-
/** Record the user's consent decision. No-op unless `requireConsent` is set. */
|
|
82
|
-
function setConsent(nextGranted) {
|
|
83
|
-
const previous = hasConsent();
|
|
84
|
-
consentState().granted = nextGranted;
|
|
85
|
-
updateEffectiveConsent(previous);
|
|
86
|
-
}
|
|
87
|
-
/** Whether capture may proceed at all. */
|
|
88
|
-
function hasConsent() {
|
|
89
|
-
const state = consentState();
|
|
90
|
-
return !state.requireConsent || state.granted;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Subscribe to effective capture permission changes. The listener is invoked
|
|
94
|
-
* only when capture actually transitions between allowed and denied; changing
|
|
95
|
-
* the stored decision while `requireConsent` is off is deliberately silent.
|
|
96
|
-
*/
|
|
97
|
-
function onConsentChange(listener) {
|
|
98
|
-
const { listeners } = consentState();
|
|
99
|
-
listeners.add(listener);
|
|
100
|
-
return () => listeners.delete(listener);
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Earliest epoch-ms timestamp telemetry may export for the current grant.
|
|
104
|
-
* `Infinity` means capture is denied. Exporters use this to discard anything
|
|
105
|
-
* buffered before a late grant or across a revoke/re-grant cycle.
|
|
106
|
-
*/
|
|
107
|
-
function consentAllowedSince() {
|
|
108
|
-
return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Whether a *persistent* identifier may be stored. Separate from `hasConsent`
|
|
112
|
-
* because GPC/DNT suppress the cross-session id without suppressing capture.
|
|
113
|
-
*/
|
|
114
|
-
function mayPersistIdentifier() {
|
|
115
|
-
if (!hasConsent()) return false;
|
|
116
|
-
if (typeof navigator === "undefined") return true;
|
|
117
|
-
const nav = navigator;
|
|
118
|
-
if (nav.globalPrivacyControl === true) return false;
|
|
119
|
-
if (consentState().respectDoNotTrack && nav.doNotTrack === "1") return false;
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
//#endregion
|
|
123
|
-
//#region ../browser-session/src/identity/identity.ts
|
|
124
|
-
/**
|
|
125
|
-
* Trait caps. The warehouse column is `Map(String, String)` — plain keys
|
|
126
|
-
* precisely because they are arbitrary — so the cost of a unique key per user
|
|
127
|
-
* is row size and query-time map width rather than a churned dictionary. The
|
|
128
|
-
* cap bounds one row's damage; `warnOnIdLikeTraitKey` surfaces the pattern to
|
|
129
|
-
* whoever wrote it, since an id in a trait key is nearly always a mistake.
|
|
130
|
-
*/
|
|
131
|
-
const MAX_TRAITS = 24;
|
|
132
|
-
const MAX_TRAIT_KEY_LENGTH = 64;
|
|
133
|
-
const MAX_TRAIT_VALUE_LENGTH = 256;
|
|
134
|
-
let warnedAboutTraitKeys = false;
|
|
135
|
-
/** Heuristic: a trait *key* that looks like an id is almost always a mistake. */
|
|
136
|
-
function looksLikeId(key) {
|
|
137
|
-
return /^[0-9a-f]{8,}$/i.test(key) || /^(usr|user|org|cus|acct)_/i.test(key);
|
|
138
|
-
}
|
|
139
|
-
function coerceTraitValue(value) {
|
|
140
|
-
if (value === null || value === void 0) return void 0;
|
|
141
|
-
return (typeof value === "string" ? value : String(value)).slice(0, MAX_TRAIT_VALUE_LENGTH);
|
|
142
|
-
}
|
|
143
|
-
function normalizeTraits(traits) {
|
|
144
|
-
if (!traits) return {};
|
|
145
|
-
const out = {};
|
|
146
|
-
for (const [rawKey, rawValue] of Object.entries(traits)) {
|
|
147
|
-
if (Object.keys(out).length >= MAX_TRAITS) break;
|
|
148
|
-
const value = coerceTraitValue(rawValue);
|
|
149
|
-
if (value === void 0) continue;
|
|
150
|
-
const key = rawKey.slice(0, MAX_TRAIT_KEY_LENGTH);
|
|
151
|
-
if (!key) continue;
|
|
152
|
-
if (!warnedAboutTraitKeys && looksLikeId(key)) {
|
|
153
|
-
warnedAboutTraitKeys = true;
|
|
154
|
-
console.warn(`[maple] identity trait key "${key}" looks like an id. Trait keys share a ClickHouse dictionary — put ids in the value, or in id/groupId, not the key.`);
|
|
155
|
-
}
|
|
156
|
-
out[key] = value;
|
|
157
|
-
}
|
|
158
|
-
return out;
|
|
159
|
-
}
|
|
160
|
-
function trimmed(value) {
|
|
161
|
-
const text = value?.trim();
|
|
162
|
-
return text ? text : void 0;
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Normalize whatever a host app passed into the shape the metadata row wants.
|
|
166
|
-
*
|
|
167
|
-
* Each call *replaces* the identity rather than merging into the previous one.
|
|
168
|
-
* Merging would leak a signed-out user's email into the next person to use a
|
|
169
|
-
* shared device, and replacement is what the existing string-only `identify`
|
|
170
|
-
* already did.
|
|
171
|
-
*/
|
|
172
|
-
function normalizeIdentity(input) {
|
|
173
|
-
if (input === null || input === void 0) return void 0;
|
|
174
|
-
if (typeof input === "string") {
|
|
175
|
-
const id = trimmed(input);
|
|
176
|
-
return id ? {
|
|
177
|
-
id,
|
|
178
|
-
traits: {}
|
|
179
|
-
} : void 0;
|
|
180
|
-
}
|
|
181
|
-
const resolved = {
|
|
182
|
-
id: trimmed(input.id),
|
|
183
|
-
email: trimmed(input.email),
|
|
184
|
-
username: trimmed(input.username),
|
|
185
|
-
groupId: trimmed(input.groupId),
|
|
186
|
-
groupName: trimmed(input.groupName),
|
|
187
|
-
traits: normalizeTraits(input.traits)
|
|
188
|
-
};
|
|
189
|
-
return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
|
|
190
|
-
}
|
|
191
|
-
//#endregion
|
|
192
|
-
//#region ../browser-session/src/session/metadata-session.ts
|
|
193
|
-
/**
|
|
194
|
-
* Metadata-only session lifecycle used whenever rrweb is disabled or unsampled.
|
|
195
|
-
* It is the shared lifecycle driver with no capture of its own: the distilled
|
|
196
|
-
* event sink runs on every page load regardless, so this owner only has to post
|
|
197
|
-
* rows and flush that sink on the way out.
|
|
198
|
-
*/
|
|
199
|
-
function startMetadataSession(options) {
|
|
200
|
-
return startSessionLifecycle(options, {
|
|
201
|
-
recorded: false,
|
|
202
|
-
post: (row, keepalive) => {
|
|
203
|
-
postSessionMetaRow(options, row, keepalive);
|
|
204
|
-
},
|
|
205
|
-
onSuspend: ({ flush, keepalive }) => {
|
|
206
|
-
if (flush && keepalive) getActiveSink()?.flush(true);
|
|
207
|
-
},
|
|
208
|
-
onSessionChange: options.onSessionChange
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
//#endregion
|
|
212
|
-
//#region ../browser-session/src/events/track.ts
|
|
213
|
-
/**
|
|
214
|
-
* Caps mirroring what the ingest gateway enforces. Applying them here too means
|
|
215
|
-
* an over-sized event is trimmed before it costs bandwidth, and the developer
|
|
216
|
-
* sees the same shape locally that the warehouse will store.
|
|
217
|
-
*/
|
|
218
|
-
const MAX_NAME_LENGTH = 128;
|
|
219
|
-
const MAX_PROPS = 32;
|
|
220
|
-
const MAX_PROP_KEY_LENGTH = 64;
|
|
221
|
-
const MAX_PROP_VALUE_LENGTH = 1024;
|
|
222
|
-
const MAX_TOTAL_PROP_BYTES = 8192;
|
|
223
|
-
let warnedAboutName = false;
|
|
224
|
-
/**
|
|
225
|
-
* Coerce one property value to the string the warehouse column holds.
|
|
226
|
-
*
|
|
227
|
-
* `null`/`undefined`/functions/symbols are dropped rather than stringified —
|
|
228
|
-
* `"undefined"` as a stored value is worse than an absent key.
|
|
229
|
-
*/
|
|
230
|
-
function coerce(value) {
|
|
231
|
-
if (value === null || value === void 0) return void 0;
|
|
232
|
-
switch (typeof value) {
|
|
233
|
-
case "string": return value.slice(0, MAX_PROP_VALUE_LENGTH);
|
|
234
|
-
case "number":
|
|
235
|
-
case "boolean":
|
|
236
|
-
case "bigint": return String(value);
|
|
237
|
-
case "function":
|
|
238
|
-
case "symbol": return;
|
|
239
|
-
}
|
|
240
|
-
try {
|
|
241
|
-
if (value instanceof Date) return value.toISOString();
|
|
242
|
-
return JSON.stringify(value)?.slice(0, MAX_PROP_VALUE_LENGTH);
|
|
243
|
-
} catch {
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
function coerceProps(props) {
|
|
248
|
-
if (!props) return {};
|
|
249
|
-
const out = {};
|
|
250
|
-
let bytes = 0;
|
|
251
|
-
for (const [rawKey, rawValue] of Object.entries(props)) {
|
|
252
|
-
if (Object.keys(out).length >= MAX_PROPS) break;
|
|
253
|
-
const value = coerce(rawValue);
|
|
254
|
-
if (value === void 0) continue;
|
|
255
|
-
const key = rawKey.slice(0, MAX_PROP_KEY_LENGTH);
|
|
256
|
-
if (!key) continue;
|
|
257
|
-
bytes += key.length + value.length;
|
|
258
|
-
if (bytes > MAX_TOTAL_PROP_BYTES) break;
|
|
259
|
-
out[key] = value;
|
|
260
|
-
}
|
|
261
|
-
return out;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Record a custom product event against the current session.
|
|
265
|
-
*
|
|
266
|
-
* Stored as a `session_events` row with `Type='custom'`, so it shows up inline
|
|
267
|
-
* in the session transcript alongside the clicks and network calls that
|
|
268
|
-
* surround it — not in a separate analytics silo.
|
|
269
|
-
*
|
|
270
|
-
* Safe to call before the SDK finishes initializing: events are queued (capped)
|
|
271
|
-
* and drained once the sink starts. Never throws.
|
|
272
|
-
*/
|
|
273
|
-
function track(name, props) {
|
|
274
|
-
if (!hasConsent()) return;
|
|
275
|
-
if (typeof name !== "string" || name.trim().length === 0) {
|
|
276
|
-
if (!warnedAboutName) {
|
|
277
|
-
warnedAboutName = true;
|
|
278
|
-
console.warn("[maple] track() needs a non-empty event name; the call was ignored.");
|
|
279
|
-
}
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
const ev = {
|
|
283
|
-
type: "custom",
|
|
284
|
-
message: name.trim().slice(0, MAX_NAME_LENGTH),
|
|
285
|
-
attrs: coerceProps(props),
|
|
286
|
-
timestamp: Date.now(),
|
|
287
|
-
url: typeof location !== "undefined" ? location.href : void 0
|
|
288
|
-
};
|
|
289
|
-
const sink = getActiveSink();
|
|
290
|
-
if (sink) sink.emit(ev);
|
|
291
|
-
else queuePending(ev);
|
|
292
|
-
}
|
|
293
|
-
//#endregion
|
|
294
|
-
//#region src/config.ts
|
|
295
|
-
const DEFAULT_ENDPOINT = "https://ingest.maple.dev";
|
|
296
|
-
/**
|
|
297
|
-
* Resolve the identity from either the new `user` object or the legacy
|
|
298
|
-
* `userId` string. `user` wins when both are set.
|
|
299
|
-
*/
|
|
300
|
-
function resolveIdentity(config) {
|
|
301
|
-
return normalizeIdentity(config.user ?? config.userId);
|
|
302
|
-
}
|
|
303
|
-
function resolveConfig(config) {
|
|
304
|
-
return {
|
|
305
|
-
ingestKey: config.ingestKey,
|
|
306
|
-
serviceName: config.serviceName,
|
|
307
|
-
endpoint: (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
|
|
308
|
-
serviceNamespace: config.serviceNamespace,
|
|
309
|
-
serviceVersion: config.serviceVersion,
|
|
310
|
-
environment: config.environment,
|
|
311
|
-
identity: resolveIdentity(config),
|
|
312
|
-
tracingEnabled: config.tracing?.enabled ?? true,
|
|
313
|
-
tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
|
|
314
|
-
replayEnabled: config.replay?.enabled ?? true,
|
|
315
|
-
replaySampleRate: config.replay?.sampleRate ?? 1,
|
|
316
|
-
maskAllInputs: config.privacy?.maskAllInputs ?? true,
|
|
317
|
-
maskAllText: config.privacy?.maskAllText ?? false,
|
|
318
|
-
persistVisitorId: config.privacy?.persistVisitorId ?? true,
|
|
319
|
-
crossSubdomainCookie: config.privacy?.crossSubdomainCookie ?? true,
|
|
320
|
-
cookieDomain: config.privacy?.cookieDomain,
|
|
321
|
-
requireConsent: config.privacy?.requireConsent ?? false,
|
|
322
|
-
captureUserEmail: config.privacy?.captureUserEmail ?? true,
|
|
323
|
-
respectDoNotTrack: config.privacy?.respectDoNotTrack ?? false
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
//#endregion
|
|
327
|
-
//#region src/version.ts
|
|
328
|
-
const SDK_VERSION = "0.8.0";
|
|
329
|
-
/** The `x-maple-sdk` value this build sends. */
|
|
330
|
-
const SDK_NAME = "maple-browser";
|
|
331
|
-
//#endregion
|
|
332
|
-
//#region src/tracing.ts
|
|
333
|
-
/**
|
|
334
|
-
* Captures every span's trace id into the session sink. Lightweight — runs
|
|
335
|
-
* alongside the BatchSpanProcessor, does no export of its own.
|
|
336
|
-
*/
|
|
337
|
-
var TraceIdCollector = class {
|
|
338
|
-
getUserId;
|
|
339
|
-
constructor(getUserId = () => void 0) {
|
|
340
|
-
this.getUserId = getUserId;
|
|
341
|
-
}
|
|
342
|
-
onStart(span) {
|
|
343
|
-
if (!hasConsent()) return;
|
|
344
|
-
recordTraceId(span.spanContext().traceId);
|
|
345
|
-
const sessionId = readSessionSink()?.sessionId;
|
|
346
|
-
if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
|
|
347
|
-
const userId = this.getUserId();
|
|
348
|
-
if (userId !== void 0) span.setAttribute("user.id", userId);
|
|
349
|
-
}
|
|
350
|
-
onEnd(_span) {}
|
|
351
|
-
forceFlush() {
|
|
352
|
-
return Promise.resolve();
|
|
353
|
-
}
|
|
354
|
-
shutdown() {
|
|
355
|
-
return Promise.resolve();
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
|
-
/**
|
|
359
|
-
* Drops buffered spans captured while consent was absent. Checking start time,
|
|
360
|
-
* rather than only permission at flush time, also prevents a late grant from
|
|
361
|
-
* releasing spans that began before that grant.
|
|
362
|
-
*/
|
|
363
|
-
var ConsentSpanExporter = class {
|
|
364
|
-
inner;
|
|
365
|
-
constructor(inner) {
|
|
366
|
-
this.inner = inner;
|
|
367
|
-
}
|
|
368
|
-
export(spans, callback) {
|
|
369
|
-
const since = consentAllowedSince();
|
|
370
|
-
if (!hasConsent() || !Number.isFinite(since)) {
|
|
371
|
-
callback({ code: 0 });
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
const eligible = spans.filter((span) => span.startTime[0] * 1e3 + span.startTime[1] / 1e6 >= since);
|
|
375
|
-
if (eligible.length === 0) {
|
|
376
|
-
callback({ code: 0 });
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
this.inner.export(eligible, callback);
|
|
380
|
-
}
|
|
381
|
-
forceFlush() {
|
|
382
|
-
return this.inner.forceFlush?.() ?? Promise.resolve();
|
|
383
|
-
}
|
|
384
|
-
shutdown() {
|
|
385
|
-
return this.inner.shutdown();
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
/**
|
|
389
|
-
* How long a span may sit in the batch queue before export.
|
|
390
|
-
*
|
|
391
|
-
* Shorter than OTel's 5s default: in a browser the queue is only as durable as
|
|
392
|
-
* the tab, and the unload flush below is a best-effort catch rather than a
|
|
393
|
-
* guarantee (a crashed or killed tab fires neither event). 2s trades a few more
|
|
394
|
-
* requests for a materially smaller loss window.
|
|
395
|
-
*/
|
|
396
|
-
const EXPORT_INTERVAL_MS = 2e3;
|
|
397
|
-
/**
|
|
398
|
-
* Set up browser OTel tracing exporting to Maple's ingest. When
|
|
399
|
-
* `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
|
|
400
|
-
* their trace ids feed the session. Disable it when an external tracer (e.g.
|
|
401
|
-
* the Effect client SDK) already instruments requests — that tracer feeds the
|
|
402
|
-
* session via the published sink instead, and this avoids redundant duplicate
|
|
403
|
-
* network spans. Returns a shutdown function.
|
|
404
|
-
*
|
|
405
|
-
* `session.id` is deliberately **not** a resource attribute: the resource is
|
|
406
|
-
* fixed for the provider's lifetime, but sessions rotate under it (idle
|
|
407
|
-
* rotation, consent revoke→re-grant), so a resource-level id would attribute
|
|
408
|
-
* every post-rotation span to the ended session. `TraceIdCollector` stamps the
|
|
409
|
-
* live id per span instead.
|
|
410
|
-
*/
|
|
411
|
-
function setupTracing(config) {
|
|
412
|
-
const attributes = {
|
|
413
|
-
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
414
|
-
"maple.sdk.type": "browser"
|
|
415
|
-
};
|
|
416
|
-
if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
|
|
417
|
-
if (config.serviceVersion) {
|
|
418
|
-
attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
|
|
419
|
-
attributes["deployment.commit_sha"] = config.serviceVersion;
|
|
420
|
-
}
|
|
421
|
-
if (config.environment) {
|
|
422
|
-
attributes["deployment.environment"] = config.environment;
|
|
423
|
-
attributes["deployment.environment.name"] = config.environment;
|
|
424
|
-
}
|
|
425
|
-
const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
|
|
426
|
-
url: `${config.endpoint}/v1/traces`,
|
|
427
|
-
headers: {
|
|
428
|
-
Authorization: `Bearer ${config.ingestKey}`,
|
|
429
|
-
[SDK_HINT_HEADER]: sdkHint(SDK_NAME, SDK_VERSION)
|
|
430
|
-
}
|
|
431
|
-
}));
|
|
432
|
-
const provider = new WebTracerProvider({
|
|
433
|
-
resource: resourceFromAttributes(attributes),
|
|
434
|
-
spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
|
|
435
|
-
});
|
|
436
|
-
provider.register();
|
|
437
|
-
const onExit = () => {
|
|
438
|
-
provider.forceFlush().catch(() => {});
|
|
439
|
-
};
|
|
440
|
-
const onVisibilityChange = () => {
|
|
441
|
-
if (document.visibilityState === "hidden") onExit();
|
|
442
|
-
};
|
|
443
|
-
const canListen = typeof document !== "undefined" && typeof document.addEventListener === "function";
|
|
444
|
-
if (canListen) {
|
|
445
|
-
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
446
|
-
window.addEventListener("pagehide", onExit);
|
|
447
|
-
}
|
|
448
|
-
const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
|
|
449
|
-
return async () => {
|
|
450
|
-
if (canListen) {
|
|
451
|
-
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
452
|
-
window.removeEventListener("pagehide", onExit);
|
|
453
|
-
}
|
|
454
|
-
unregisterInstrumentations?.();
|
|
455
|
-
await provider.shutdown();
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
function escapeRegExp(value) {
|
|
459
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
460
|
-
}
|
|
461
|
-
//#endregion
|
|
462
|
-
//#region src/init.ts
|
|
463
|
-
/** `x-maple-sdk` value for every request this build makes to ingest. */
|
|
464
|
-
const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
|
|
465
|
-
let active;
|
|
466
|
-
let activeConfig;
|
|
467
|
-
/**
|
|
468
|
-
* Initialize Maple browser telemetry. With consent gating enabled the returned
|
|
469
|
-
* handle remains live while denied: granting starts capture, revoking detaches
|
|
470
|
-
* it without flushing, and a later grant starts cleanly again.
|
|
471
|
-
*/
|
|
472
|
-
function init(rawConfig) {
|
|
473
|
-
if (active) return active;
|
|
474
|
-
if (typeof window === "undefined") return {
|
|
475
|
-
sessionId: "",
|
|
476
|
-
shutdown: () => Promise.resolve()
|
|
477
|
-
};
|
|
478
|
-
const config = resolveConfig(rawConfig);
|
|
479
|
-
activeConfig = config;
|
|
480
|
-
configurePrivacy(config);
|
|
481
|
-
if (!hasConsent()) clearPendingEvents();
|
|
482
|
-
setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
|
|
483
|
-
const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
|
|
484
|
-
let runtime;
|
|
485
|
-
let stopped = false;
|
|
486
|
-
let rotateOnNextStart = false;
|
|
487
|
-
let shutdownTracing;
|
|
488
|
-
let generation = 0;
|
|
489
|
-
const startRuntime = () => {
|
|
490
|
-
if (stopped || runtime || !hasConsent()) return;
|
|
491
|
-
setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
|
|
492
|
-
const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
|
|
493
|
-
rotateOnNextStart = false;
|
|
494
|
-
publishSessionSink(session.id);
|
|
495
|
-
const sink = startEventSink({
|
|
496
|
-
endpoint: config.endpoint,
|
|
497
|
-
ingestKey: config.ingestKey,
|
|
498
|
-
sdk: SDK_HINT,
|
|
499
|
-
maskAllInputs: config.maskAllInputs,
|
|
500
|
-
maskAllText: config.maskAllText
|
|
501
|
-
}, session.id);
|
|
502
|
-
if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
|
|
503
|
-
const shared = {
|
|
504
|
-
endpoint: config.endpoint,
|
|
505
|
-
ingestKey: config.ingestKey,
|
|
506
|
-
sdk: SDK_HINT,
|
|
507
|
-
serviceName: config.serviceName,
|
|
508
|
-
environment: config.environment,
|
|
509
|
-
serviceVersion: config.serviceVersion,
|
|
510
|
-
getIdentity: () => activeConfig?.identity,
|
|
511
|
-
captureUserEmail: config.captureUserEmail
|
|
512
|
-
};
|
|
513
|
-
const startMetadata = () => startMetadataSession({
|
|
514
|
-
...shared,
|
|
515
|
-
getTraceIds: getObservedTraceIds,
|
|
516
|
-
onSessionChange: publishSessionSink
|
|
517
|
-
});
|
|
518
|
-
if (!recordReplay) {
|
|
519
|
-
runtime = {
|
|
520
|
-
initialSessionId: session.id,
|
|
521
|
-
sink,
|
|
522
|
-
metadata: startMetadata()
|
|
523
|
-
};
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
const next = {
|
|
527
|
-
initialSessionId: session.id,
|
|
528
|
-
sink
|
|
529
|
-
};
|
|
530
|
-
runtime = next;
|
|
531
|
-
const ownGeneration = ++generation;
|
|
532
|
-
const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
|
|
533
|
-
next.replayPending = import("./replay-session-BYZfi3iZ.mjs").then(({ startReplaySession }) => {
|
|
534
|
-
if (stale()) return;
|
|
535
|
-
next.replay = startReplaySession({
|
|
536
|
-
...shared,
|
|
537
|
-
maskAllInputs: config.maskAllInputs,
|
|
538
|
-
maskAllText: config.maskAllText
|
|
539
|
-
});
|
|
540
|
-
}).catch(() => {
|
|
541
|
-
if (stale()) return;
|
|
542
|
-
next.metadata = startMetadata();
|
|
543
|
-
});
|
|
544
|
-
};
|
|
545
|
-
const stopRuntime = async (flush) => {
|
|
546
|
-
generation++;
|
|
547
|
-
const previous = runtime;
|
|
548
|
-
runtime = void 0;
|
|
549
|
-
if (!previous) return;
|
|
550
|
-
const replayShutdown = previous.replay?.shutdown({ flush });
|
|
551
|
-
const metadataShutdown = previous.metadata?.shutdown({ flush });
|
|
552
|
-
const currentSessionId = previous.replay?.sessionId ?? previous.metadata?.sessionId;
|
|
553
|
-
const liveSink = getActiveSink();
|
|
554
|
-
const sink = liveSink && currentSessionId && liveSink.sessionId === currentSessionId ? liveSink : previous.sink;
|
|
555
|
-
if (flush) await sink.flush(true);
|
|
556
|
-
sink.stop();
|
|
557
|
-
if (sink !== previous.sink) previous.sink.stop();
|
|
558
|
-
clearSessionSink(currentSessionId ?? previous.initialSessionId);
|
|
559
|
-
await Promise.all([
|
|
560
|
-
replayShutdown,
|
|
561
|
-
metadataShutdown,
|
|
562
|
-
previous.replayPending
|
|
563
|
-
]);
|
|
564
|
-
};
|
|
565
|
-
startRuntime();
|
|
566
|
-
const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => {
|
|
567
|
-
if (allowed) {
|
|
568
|
-
startRuntime();
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
rotateOnNextStart = runtime !== void 0;
|
|
572
|
-
clearPendingEvents();
|
|
573
|
-
setVisitorTracking(false);
|
|
574
|
-
stopRuntime(false);
|
|
575
|
-
}) : () => {};
|
|
576
|
-
const handle = {
|
|
577
|
-
get sessionId() {
|
|
578
|
-
return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
|
|
579
|
-
},
|
|
580
|
-
shutdown: async () => {
|
|
581
|
-
if (stopped) return;
|
|
582
|
-
stopped = true;
|
|
583
|
-
stopConsentListener();
|
|
584
|
-
await stopRuntime(true);
|
|
585
|
-
await shutdownTracing?.();
|
|
586
|
-
shutdownTracing = void 0;
|
|
587
|
-
setActiveTraceIdProvider(() => void 0);
|
|
588
|
-
active = void 0;
|
|
589
|
-
activeConfig = void 0;
|
|
590
|
-
}
|
|
591
|
-
};
|
|
592
|
-
active = handle;
|
|
593
|
-
return handle;
|
|
594
|
-
}
|
|
595
|
-
/**
|
|
596
|
-
* Attach, replace, or clear the end-user identity on the active session.
|
|
597
|
-
* Idempotent and safe to call on every render. Future browser-created spans
|
|
598
|
-
* read the id when they start, and future session metadata rows read the whole
|
|
599
|
-
* identity when they post.
|
|
600
|
-
*
|
|
601
|
-
* Accepts a bare user id or the full object:
|
|
602
|
-
*
|
|
603
|
-
* ```ts
|
|
604
|
-
* MapleBrowser.identify("user_123")
|
|
605
|
-
* MapleBrowser.identify({ id: "user_123", email: "a@b.com", groupId: "org_1", groupName: "Acme" })
|
|
606
|
-
* ```
|
|
607
|
-
*
|
|
608
|
-
* Each call replaces the identity rather than merging — merging would leak a
|
|
609
|
-
* signed-out user's email into whoever signs in next on a shared device.
|
|
610
|
-
*/
|
|
611
|
-
function identify(input) {
|
|
612
|
-
if (typeof window === "undefined" || !activeConfig) return;
|
|
613
|
-
activeConfig.identity = normalizeIdentity(input);
|
|
614
|
-
}
|
|
615
|
-
//#endregion
|
|
616
|
-
//#region src/index.ts
|
|
617
|
-
/**
|
|
618
|
-
* Maple browser SDK. One call wires up OpenTelemetry tracing and rrweb session
|
|
619
|
-
* replay, both tagged with a shared session id.
|
|
620
|
-
*
|
|
621
|
-
* @example
|
|
622
|
-
* ```ts
|
|
623
|
-
* import { MapleBrowser } from "@maple-dev/browser"
|
|
624
|
-
*
|
|
625
|
-
* MapleBrowser.init({
|
|
626
|
-
* ingestKey: "maple_pk_...",
|
|
627
|
-
* serviceName: "acme-web",
|
|
628
|
-
* })
|
|
629
|
-
*
|
|
630
|
-
* MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
|
|
631
|
-
* MapleBrowser.track("checkout_completed", { plan: "pro", seats: 5 })
|
|
632
|
-
* ```
|
|
633
|
-
*/
|
|
634
|
-
const MapleBrowser = {
|
|
635
|
-
init,
|
|
636
|
-
identify,
|
|
637
|
-
track,
|
|
638
|
-
setConsent
|
|
639
|
-
};
|
|
640
|
-
//#endregion
|
|
1
|
+
import { t as MapleBrowser } from "./src-Bzmp8rVr.mjs";
|
|
641
2
|
export { MapleBrowser };
|