@maple-dev/browser 0.3.0 → 0.4.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/LICENSE +21 -0
- package/README.md +36 -1
- package/dist/index.d.mts +92 -8
- package/dist/index.mjs +437 -727
- package/dist/replay-session-B_ACRogX.mjs +390 -0
- package/dist/sink-DHTfvFgl.mjs +1149 -0
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,737 +1,304 @@
|
|
|
1
|
+
import { S as setVisitorTracking, a as recordTraceId, b as postSessionMetaRow, c as clearPendingEvents, d as setActiveTraceIdProvider, f as startEventSink, g as rotateSession, i as readSessionSink, l as getActiveSink, n as getObservedTraceIds, o as startSessionLifecycle, p as getSession, r as publishSessionSink, t as clearSessionSink, u as queuePending, x as configureVisitorCookie } from "./sink-DHTfvFgl.mjs";
|
|
1
2
|
import { trace } from "@opentelemetry/api";
|
|
2
|
-
import { record } from "rrweb";
|
|
3
|
-
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
|
|
4
|
-
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
5
3
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
6
|
-
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
7
4
|
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
|
8
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
9
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
10
|
-
//#region ../browser-session/src/
|
|
11
|
-
const STORAGE_KEY = "maple.session";
|
|
12
|
-
/** Rotate the session after this much inactivity (PostHog's default). */
|
|
13
|
-
const IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
14
|
-
/** Hard cap on a single session's lifetime regardless of activity. */
|
|
15
|
-
const MAX_SESSION_MS = 1440 * 6e4;
|
|
16
|
-
/** In-memory fallback when sessionStorage is unavailable (private mode). */
|
|
17
|
-
let ephemeral;
|
|
18
|
-
function freshRecord(now) {
|
|
19
|
-
return {
|
|
20
|
-
id: crypto.randomUUID(),
|
|
21
|
-
startedAt: now,
|
|
22
|
-
lastActivityAt: now,
|
|
23
|
-
chunkSeq: 0,
|
|
24
|
-
metaVersion: 0
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
function readRecord() {
|
|
28
|
-
try {
|
|
29
|
-
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
30
|
-
if (!raw) return void 0;
|
|
31
|
-
const parsed = JSON.parse(raw);
|
|
32
|
-
if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
|
|
33
|
-
return;
|
|
34
|
-
} catch {
|
|
35
|
-
return ephemeral;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
function writeRecord(record) {
|
|
39
|
-
ephemeral = record;
|
|
40
|
-
try {
|
|
41
|
-
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
42
|
-
} catch {}
|
|
43
|
-
}
|
|
44
|
-
function isExpired(record, now) {
|
|
45
|
-
return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
|
|
46
|
-
}
|
|
10
|
+
//#region ../browser-session/src/consent.ts
|
|
47
11
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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.
|
|
52
27
|
*/
|
|
53
|
-
function getSession() {
|
|
54
|
-
const now = Date.now();
|
|
55
|
-
const existing = readRecord();
|
|
56
|
-
const record = existing && !isExpired(existing, now) ? {
|
|
57
|
-
...existing,
|
|
58
|
-
lastActivityAt: now
|
|
59
|
-
} : freshRecord(now);
|
|
60
|
-
writeRecord(record);
|
|
61
|
-
return record;
|
|
62
|
-
}
|
|
63
|
-
/** Mark the session as active right now (called as replay chunks flush). */
|
|
64
|
-
function markActivity() {
|
|
65
|
-
const record = readRecord();
|
|
66
|
-
if (!record) return;
|
|
67
|
-
writeRecord({
|
|
68
|
-
...record,
|
|
69
|
-
lastActivityAt: Date.now()
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
28
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
29
|
+
* Consent lives on `globalThis`, for the same reason the event sink does
|
|
30
|
+
* (`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.
|
|
76
35
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 {}
|
|
85
59
|
}
|
|
86
60
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* SDKs
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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.
|
|
93
69
|
*/
|
|
94
|
-
function
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
100
78
|
});
|
|
101
|
-
|
|
79
|
+
updateEffectiveConsent(previous);
|
|
102
80
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
function recordTraceId(traceId) {
|
|
109
|
-
observedTraceIds.add(traceId);
|
|
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);
|
|
110
86
|
}
|
|
111
|
-
|
|
112
|
-
|
|
87
|
+
/** Whether capture may proceed at all. */
|
|
88
|
+
function hasConsent() {
|
|
89
|
+
const state = consentState();
|
|
90
|
+
return !state.requireConsent || state.granted;
|
|
113
91
|
}
|
|
114
92
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* side, so init ordering between SDKs does not matter.
|
|
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.
|
|
119
96
|
*/
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
//#endregion
|
|
127
|
-
//#region ../browser-session/src/user-agent.ts
|
|
128
|
-
/** Best-effort UA parse — enough to populate filterable session facets. */
|
|
129
|
-
function parseUserAgent(ua) {
|
|
130
|
-
return {
|
|
131
|
-
browserName: /edg/i.test(ua) ? "Edge" : /opr|opera/i.test(ua) ? "Opera" : /chrome|crios/i.test(ua) ? "Chrome" : /firefox|fxios/i.test(ua) ? "Firefox" : /safari/i.test(ua) ? "Safari" : "Unknown",
|
|
132
|
-
osName: /windows/i.test(ua) ? "Windows" : /iphone|ipad|ios/i.test(ua) ? "iOS" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /linux/i.test(ua) ? "Linux" : "Unknown",
|
|
133
|
-
deviceType: /mobile|iphone|android.*mobile/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
//#endregion
|
|
137
|
-
//#region ../browser-session/src/meta-row.ts
|
|
138
|
-
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
139
|
-
function formatCHDateTime(date) {
|
|
140
|
-
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
141
|
-
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
|
|
97
|
+
function onConsentChange(listener) {
|
|
98
|
+
const { listeners } = consentState();
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
return () => listeners.delete(listener);
|
|
142
101
|
}
|
|
143
102
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* exotic embedders) they fall back to empty strings.
|
|
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.
|
|
148
106
|
*/
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
const userAgent = g["navigator"]?.userAgent ?? "";
|
|
152
|
-
const ua = parseUserAgent(userAgent);
|
|
153
|
-
const now = /* @__PURE__ */ new Date();
|
|
154
|
-
const row = {
|
|
155
|
-
session_id: input.sessionId,
|
|
156
|
-
start_time: formatCHDateTime(input.startedAt),
|
|
157
|
-
status: input.status,
|
|
158
|
-
version: input.version,
|
|
159
|
-
user_id: input.userId ?? "",
|
|
160
|
-
url_initial: g["window"]?.location?.href ?? "",
|
|
161
|
-
user_agent: userAgent,
|
|
162
|
-
browser_name: ua.browserName,
|
|
163
|
-
os_name: ua.osName,
|
|
164
|
-
device_type: ua.deviceType,
|
|
165
|
-
service_name: input.serviceName,
|
|
166
|
-
resource_attributes: {
|
|
167
|
-
...input.environment ? {
|
|
168
|
-
"deployment.environment": input.environment,
|
|
169
|
-
"deployment.environment.name": input.environment
|
|
170
|
-
} : {},
|
|
171
|
-
...input.serviceVersion ? { "deployment.commit_sha": input.serviceVersion } : {}
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
if (input.status === "ended") {
|
|
175
|
-
row.end_time = formatCHDateTime(now);
|
|
176
|
-
row.duration_ms = Math.max(0, now.getTime() - input.startedAt.getTime());
|
|
177
|
-
row.click_count = input.clickCount ?? 0;
|
|
178
|
-
row.trace_ids = input.traceIds ? Array.from(input.traceIds) : [];
|
|
179
|
-
}
|
|
180
|
-
return row;
|
|
181
|
-
}
|
|
182
|
-
//#endregion
|
|
183
|
-
//#region ../browser-session/src/replay/transport.ts
|
|
184
|
-
let lastWarnAt = 0;
|
|
185
|
-
function warnDropped(what, error) {
|
|
186
|
-
const now = Date.now();
|
|
187
|
-
if (now - lastWarnAt < 3e4) return;
|
|
188
|
-
lastWarnAt = now;
|
|
189
|
-
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
190
|
-
}
|
|
191
|
-
/** gzip a byte buffer using the native CompressionStream (no library). */
|
|
192
|
-
async function gzip(bytes) {
|
|
193
|
-
const stream = new CompressionStream("gzip");
|
|
194
|
-
const writer = stream.writable.getWriter();
|
|
195
|
-
writer.write(bytes);
|
|
196
|
-
writer.close();
|
|
197
|
-
const buffer = await new Response(stream.readable).arrayBuffer();
|
|
198
|
-
return new Uint8Array(buffer);
|
|
107
|
+
function consentAllowedSince() {
|
|
108
|
+
return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
|
|
199
109
|
}
|
|
200
|
-
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
201
|
-
async function postSessionMeta(config, row, keepalive = false) {
|
|
202
|
-
const body = `${JSON.stringify(row)}\n`;
|
|
203
|
-
await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
|
|
204
|
-
method: "POST",
|
|
205
|
-
headers: {
|
|
206
|
-
Authorization: `Bearer ${config.ingestKey}`,
|
|
207
|
-
"content-type": "application/x-ndjson"
|
|
208
|
-
},
|
|
209
|
-
body,
|
|
210
|
-
keepalive
|
|
211
|
-
}).catch((error) => {
|
|
212
|
-
warnDropped("metadata POST", error);
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
/** POST distilled session events (NDJSON, one row per event). Best-effort. */
|
|
216
|
-
async function postSessionEvents(config, rows, keepalive = false) {
|
|
217
|
-
if (rows.length === 0) return;
|
|
218
|
-
const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
219
|
-
await fetch(`${config.endpoint}/v1/sessionEvents`, {
|
|
220
|
-
method: "POST",
|
|
221
|
-
headers: {
|
|
222
|
-
Authorization: `Bearer ${config.ingestKey}`,
|
|
223
|
-
"content-type": "application/x-ndjson"
|
|
224
|
-
},
|
|
225
|
-
body,
|
|
226
|
-
keepalive
|
|
227
|
-
}).catch((error) => {
|
|
228
|
-
warnDropped("events POST", error);
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
/** PUT a gzipped rrweb event chunk. */
|
|
232
|
-
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
233
|
-
await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
|
|
234
|
-
method: "POST",
|
|
235
|
-
headers: {
|
|
236
|
-
Authorization: `Bearer ${config.ingestKey}`,
|
|
237
|
-
"content-type": "application/octet-stream",
|
|
238
|
-
"x-maple-session-id": meta.sessionId,
|
|
239
|
-
"x-maple-chunk-seq": String(meta.chunkSeq),
|
|
240
|
-
"x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
|
|
241
|
-
"x-maple-event-count": String(meta.eventCount),
|
|
242
|
-
"x-maple-duration-ms": String(meta.durationMs)
|
|
243
|
-
},
|
|
244
|
-
body: gzipped,
|
|
245
|
-
keepalive
|
|
246
|
-
}).catch((error) => {
|
|
247
|
-
warnDropped("blob PUT", error);
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
//#endregion
|
|
251
|
-
//#region ../browser-session/src/replay/capture/shared.ts
|
|
252
|
-
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
253
|
-
function safeEmit(emit, ev) {
|
|
254
|
-
try {
|
|
255
|
-
emit(ev);
|
|
256
|
-
} catch {}
|
|
257
|
-
}
|
|
258
|
-
//#endregion
|
|
259
|
-
//#region ../browser-session/src/replay/capture/console.ts
|
|
260
|
-
const LEVELS = [
|
|
261
|
-
"log",
|
|
262
|
-
"info",
|
|
263
|
-
"warn",
|
|
264
|
-
"error",
|
|
265
|
-
"debug"
|
|
266
|
-
];
|
|
267
|
-
const MAX_MESSAGE = 2e3;
|
|
268
110
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* behaves normally. Never throws into the call site.
|
|
111
|
+
* Whether a *persistent* identifier may be stored. Separate from `hasConsent`
|
|
112
|
+
* because GPC/DNT suppress the cross-session id without suppressing capture.
|
|
272
113
|
*/
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
type: "console",
|
|
281
|
-
level,
|
|
282
|
-
message: formatArgs(args)
|
|
283
|
-
});
|
|
284
|
-
orig.apply(console, args);
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
return () => {
|
|
288
|
-
for (const level of LEVELS) {
|
|
289
|
-
const orig = original[level];
|
|
290
|
-
if (orig) console[level] = orig;
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
function formatArgs(args) {
|
|
295
|
-
const text = args.map((a) => {
|
|
296
|
-
if (typeof a === "string") return a;
|
|
297
|
-
if (a instanceof Error) return `${a.name}: ${a.message}`;
|
|
298
|
-
try {
|
|
299
|
-
return JSON.stringify(a);
|
|
300
|
-
} catch {
|
|
301
|
-
return String(a);
|
|
302
|
-
}
|
|
303
|
-
}).join(" ");
|
|
304
|
-
return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
|
|
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;
|
|
305
121
|
}
|
|
306
122
|
//#endregion
|
|
307
|
-
//#region ../browser-session/src/
|
|
123
|
+
//#region ../browser-session/src/identity.ts
|
|
308
124
|
/**
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
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.
|
|
312
130
|
*/
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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.`);
|
|
327
155
|
}
|
|
328
|
-
|
|
329
|
-
const record = (url, method, status, start, traceId, error) => {
|
|
330
|
-
if (ignoreUrl(url)) return;
|
|
331
|
-
safeEmit(emit, {
|
|
332
|
-
type: "network",
|
|
333
|
-
net: {
|
|
334
|
-
method,
|
|
335
|
-
url,
|
|
336
|
-
status,
|
|
337
|
-
durationMs: Math.round(performance.now() - start)
|
|
338
|
-
},
|
|
339
|
-
traceId,
|
|
340
|
-
...error ? { attrs: { error } } : {}
|
|
341
|
-
});
|
|
342
|
-
};
|
|
343
|
-
const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
|
|
344
|
-
const origOpen = XHR?.prototype.open;
|
|
345
|
-
const origSend = XHR?.prototype.send;
|
|
346
|
-
if (XHR && origOpen && origSend) {
|
|
347
|
-
XHR.prototype.open = function(method, url, ...rest) {
|
|
348
|
-
this.__mapleMethod = String(method).toUpperCase();
|
|
349
|
-
this.__mapleUrl = typeof url === "string" ? url : url.href;
|
|
350
|
-
return origOpen.apply(this, [
|
|
351
|
-
method,
|
|
352
|
-
url,
|
|
353
|
-
...rest
|
|
354
|
-
]);
|
|
355
|
-
};
|
|
356
|
-
XHR.prototype.send = function(...args) {
|
|
357
|
-
const meta = this;
|
|
358
|
-
const start = performance.now();
|
|
359
|
-
const traceId = activeTraceId();
|
|
360
|
-
this.addEventListener("loadend", () => {
|
|
361
|
-
record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
|
|
362
|
-
});
|
|
363
|
-
return origSend.apply(this, args);
|
|
364
|
-
};
|
|
156
|
+
out[key] = value;
|
|
365
157
|
}
|
|
366
|
-
return
|
|
367
|
-
if (origFetch) window.fetch = origFetch;
|
|
368
|
-
if (XHR && origOpen) XHR.prototype.open = origOpen;
|
|
369
|
-
if (XHR && origSend) XHR.prototype.send = origSend;
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
function requestUrl(input) {
|
|
373
|
-
if (typeof input === "string") return input;
|
|
374
|
-
if (input instanceof URL) return input.href;
|
|
375
|
-
return input.url;
|
|
158
|
+
return out;
|
|
376
159
|
}
|
|
377
|
-
function
|
|
378
|
-
|
|
160
|
+
function trimmed(value) {
|
|
161
|
+
const text = value?.trim();
|
|
162
|
+
return text ? text : void 0;
|
|
379
163
|
}
|
|
380
|
-
//#endregion
|
|
381
|
-
//#region ../browser-session/src/replay/capture/errors.ts
|
|
382
|
-
const MAX_STACK = 4e3;
|
|
383
|
-
/** Capture uncaught errors + unhandled promise rejections as session events. */
|
|
384
|
-
function installErrorCapture(emit) {
|
|
385
|
-
const onError = (event) => {
|
|
386
|
-
safeEmit(emit, {
|
|
387
|
-
type: "error",
|
|
388
|
-
level: "error",
|
|
389
|
-
message: event.message || String(event.error ?? "Error"),
|
|
390
|
-
errorStack: truncate(event.error?.stack),
|
|
391
|
-
traceId: activeTraceId()
|
|
392
|
-
});
|
|
393
|
-
};
|
|
394
|
-
const onRejection = (event) => {
|
|
395
|
-
const reason = event.reason;
|
|
396
|
-
safeEmit(emit, {
|
|
397
|
-
type: "error",
|
|
398
|
-
level: "error",
|
|
399
|
-
message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
|
|
400
|
-
errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
|
|
401
|
-
traceId: activeTraceId()
|
|
402
|
-
});
|
|
403
|
-
};
|
|
404
|
-
window.addEventListener("error", onError);
|
|
405
|
-
window.addEventListener("unhandledrejection", onRejection);
|
|
406
|
-
return () => {
|
|
407
|
-
window.removeEventListener("error", onError);
|
|
408
|
-
window.removeEventListener("unhandledrejection", onRejection);
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
function truncate(stack) {
|
|
412
|
-
if (!stack) return void 0;
|
|
413
|
-
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
414
|
-
}
|
|
415
|
-
//#endregion
|
|
416
|
-
//#region ../browser-session/src/replay/capture/navigation.ts
|
|
417
164
|
/**
|
|
418
|
-
*
|
|
419
|
-
*
|
|
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.
|
|
420
171
|
*/
|
|
421
|
-
function
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
emitNav();
|
|
438
|
-
return result;
|
|
439
|
-
};
|
|
440
|
-
history.replaceState = function(...args) {
|
|
441
|
-
const result = origReplace.apply(this, args);
|
|
442
|
-
emitNav();
|
|
443
|
-
return result;
|
|
444
|
-
};
|
|
445
|
-
window.addEventListener("popstate", emitNav);
|
|
446
|
-
window.addEventListener("hashchange", emitNav);
|
|
447
|
-
return () => {
|
|
448
|
-
history.pushState = origPush;
|
|
449
|
-
history.replaceState = origReplace;
|
|
450
|
-
window.removeEventListener("popstate", emitNav);
|
|
451
|
-
window.removeEventListener("hashchange", emitNav);
|
|
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)
|
|
452
188
|
};
|
|
189
|
+
return resolved.id || resolved.email || resolved.username || resolved.groupId || resolved.groupName || Object.keys(resolved.traits).length > 0 ? resolved : void 0;
|
|
453
190
|
}
|
|
454
191
|
//#endregion
|
|
455
|
-
//#region ../browser-session/src/
|
|
456
|
-
const MAX_TEXT = 120;
|
|
192
|
+
//#region ../browser-session/src/metadata-session.ts
|
|
457
193
|
/**
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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.
|
|
462
198
|
*/
|
|
463
|
-
function
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const target = event.target;
|
|
475
|
-
if (!(target instanceof Element)) return;
|
|
476
|
-
safeEmit(emit, {
|
|
477
|
-
type: "input",
|
|
478
|
-
targetSelector: selectorOf(target)
|
|
479
|
-
});
|
|
480
|
-
};
|
|
481
|
-
document.addEventListener("click", onClick, true);
|
|
482
|
-
document.addEventListener("input", onInput, true);
|
|
483
|
-
return () => {
|
|
484
|
-
document.removeEventListener("click", onClick, true);
|
|
485
|
-
document.removeEventListener("input", onInput, true);
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
/** A short, human-readable selector: tag + #id + .first-class. */
|
|
489
|
-
function selectorOf(el) {
|
|
490
|
-
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
|
|
491
|
-
}
|
|
492
|
-
function textOf(el) {
|
|
493
|
-
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
494
|
-
if (!text) return void 0;
|
|
495
|
-
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
|
|
199
|
+
function startMetadataSession(options) {
|
|
200
|
+
return startSessionLifecycle(options, {
|
|
201
|
+
recorded: false,
|
|
202
|
+
post: (row, keepalive) => {
|
|
203
|
+
postSessionMetaRow(options.endpoint, options.ingestKey, row, keepalive);
|
|
204
|
+
},
|
|
205
|
+
onSuspend: ({ flush, keepalive }) => {
|
|
206
|
+
if (flush && keepalive) getActiveSink()?.flush(true);
|
|
207
|
+
},
|
|
208
|
+
onSessionChange: options.onSessionChange
|
|
209
|
+
});
|
|
496
210
|
}
|
|
497
211
|
//#endregion
|
|
498
|
-
//#region ../browser-session/src/
|
|
499
|
-
/**
|
|
500
|
-
*
|
|
501
|
-
|
|
212
|
+
//#region ../browser-session/src/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
|
+
}
|
|
502
240
|
try {
|
|
503
|
-
return
|
|
241
|
+
if (value instanceof Date) return value.toISOString();
|
|
242
|
+
return JSON.stringify(value)?.slice(0, MAX_PROP_VALUE_LENGTH);
|
|
504
243
|
} catch {
|
|
505
|
-
return
|
|
244
|
+
return;
|
|
506
245
|
}
|
|
507
246
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Capture distilled session events (console, network, errors, navigation,
|
|
525
|
-
* interactions) and ship them to the ingest gateway as NDJSON rows. Best-effort
|
|
526
|
-
* and decoupled from the rrweb recorder — runs on its own flush loop.
|
|
527
|
-
*/
|
|
528
|
-
function startEventCapture(config, sessionId) {
|
|
529
|
-
let buffer = [];
|
|
530
|
-
let bufferBytes = 0;
|
|
531
|
-
let seq = 0;
|
|
532
|
-
const emit = (ev) => {
|
|
533
|
-
buffer.push({
|
|
534
|
-
ev,
|
|
535
|
-
seq: seq++
|
|
536
|
-
});
|
|
537
|
-
bufferBytes += approximateSize(ev);
|
|
538
|
-
if (bufferBytes >= FLUSH_BYTES$1) flush();
|
|
539
|
-
};
|
|
540
|
-
const flush = async (keepalive = false) => {
|
|
541
|
-
if (buffer.length === 0) return;
|
|
542
|
-
markActivity();
|
|
543
|
-
const batch = buffer;
|
|
544
|
-
buffer = [];
|
|
545
|
-
bufferBytes = 0;
|
|
546
|
-
await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
|
|
547
|
-
};
|
|
548
|
-
const ignoreUrl = (url) => url.startsWith(`${config.endpoint}/v1/`);
|
|
549
|
-
const uninstall = [
|
|
550
|
-
installNavigationCapture(emit),
|
|
551
|
-
installInteractionCapture(emit, config.maskAllText),
|
|
552
|
-
installConsoleCapture(emit),
|
|
553
|
-
installNetworkCapture(emit, ignoreUrl),
|
|
554
|
-
installErrorCapture(emit)
|
|
555
|
-
];
|
|
556
|
-
const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS$1);
|
|
557
|
-
return {
|
|
558
|
-
stop: () => {
|
|
559
|
-
clearInterval(flushTimer);
|
|
560
|
-
for (const off of uninstall) off();
|
|
561
|
-
},
|
|
562
|
-
flush
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
/** Map an internal event to the snake_case ingest row (org_id is added server-side). */
|
|
566
|
-
function toRow(sessionId, ev, seq) {
|
|
567
|
-
return {
|
|
568
|
-
session_id: sessionId,
|
|
569
|
-
timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
|
|
570
|
-
seq,
|
|
571
|
-
type: ev.type,
|
|
572
|
-
url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
|
|
573
|
-
trace_id: ev.traceId ?? activeTraceId() ?? "",
|
|
574
|
-
level: ev.level ?? "",
|
|
575
|
-
message: ev.message ?? "",
|
|
576
|
-
target_selector: ev.targetSelector ?? "",
|
|
577
|
-
target_text: ev.targetText ?? "",
|
|
578
|
-
net_method: ev.net?.method ?? "",
|
|
579
|
-
net_url: ev.net?.url ?? "",
|
|
580
|
-
net_status: ev.net?.status ?? 0,
|
|
581
|
-
net_duration_ms: ev.net?.durationMs ?? 0,
|
|
582
|
-
error_stack: ev.errorStack ?? "",
|
|
583
|
-
attributes: ev.attrs ?? {}
|
|
584
|
-
};
|
|
585
|
-
}
|
|
586
|
-
//#endregion
|
|
587
|
-
//#region ../browser-session/src/replay/record.ts
|
|
588
|
-
const FULL_SNAPSHOT = 2;
|
|
589
|
-
const INCREMENTAL = 3;
|
|
590
|
-
const SOURCE_MOUSE_INTERACTION = 2;
|
|
591
|
-
const MOUSE_CLICK = 2;
|
|
592
|
-
const FLUSH_INTERVAL_MS = 5e3;
|
|
593
|
-
const FLUSH_BYTES = 100 * 1024;
|
|
594
|
-
const CHECKOUT_EVERY_MS = 3e4;
|
|
595
|
-
function startRecording(config, sessionId) {
|
|
596
|
-
let buffer = [];
|
|
597
|
-
let bufferBytes = 0;
|
|
598
|
-
let bufferHasCheckpoint = false;
|
|
599
|
-
let clickCount = 0;
|
|
600
|
-
const flush = async (keepalive = false) => {
|
|
601
|
-
if (buffer.length === 0) return;
|
|
602
|
-
markActivity();
|
|
603
|
-
const events = buffer;
|
|
604
|
-
const isCheckpoint = bufferHasCheckpoint;
|
|
605
|
-
const seq = nextChunkSeq();
|
|
606
|
-
const first = events[0].timestamp;
|
|
607
|
-
const last = events[events.length - 1].timestamp;
|
|
608
|
-
buffer = [];
|
|
609
|
-
bufferBytes = 0;
|
|
610
|
-
bufferHasCheckpoint = false;
|
|
611
|
-
const gzipped = await gzip(new TextEncoder().encode(JSON.stringify(events)));
|
|
612
|
-
await postSessionBlob(config, {
|
|
613
|
-
sessionId,
|
|
614
|
-
chunkSeq: seq,
|
|
615
|
-
isCheckpoint,
|
|
616
|
-
eventCount: events.length,
|
|
617
|
-
durationMs: Math.max(0, last - first)
|
|
618
|
-
}, gzipped, keepalive);
|
|
619
|
-
};
|
|
620
|
-
const stop = record({
|
|
621
|
-
emit: (event, isCheckpoint) => {
|
|
622
|
-
const e = event;
|
|
623
|
-
if (isCheckpoint === true || e.type === FULL_SNAPSHOT) bufferHasCheckpoint = true;
|
|
624
|
-
if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
|
|
625
|
-
buffer.push(e);
|
|
626
|
-
bufferBytes += approximateSize(e);
|
|
627
|
-
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
628
|
-
},
|
|
629
|
-
maskAllInputs: config.maskAllInputs,
|
|
630
|
-
...config.maskAllText ? { maskTextSelector: "*" } : {},
|
|
631
|
-
checkoutEveryNms: CHECKOUT_EVERY_MS
|
|
632
|
-
});
|
|
633
|
-
const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
|
|
634
|
-
return {
|
|
635
|
-
stop: () => {
|
|
636
|
-
clearInterval(flushTimer);
|
|
637
|
-
stop?.();
|
|
638
|
-
},
|
|
639
|
-
flush,
|
|
640
|
-
getClickCount: () => clickCount
|
|
641
|
-
};
|
|
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;
|
|
642
262
|
}
|
|
643
|
-
//#endregion
|
|
644
|
-
//#region ../browser-session/src/replay-session.ts
|
|
645
263
|
/**
|
|
646
|
-
*
|
|
647
|
-
* posts an `active` metadata row, and installs visibility handlers:
|
|
648
|
-
* hidden → flush + `ended` row (with observed trace ids) + stop capture;
|
|
649
|
-
* visible → re-resolve the session (rotating if idle-expired), republish the
|
|
650
|
-
* sink, restart capture, post a fresh `active` row. Metadata versions are
|
|
651
|
-
* monotonic per session, so the latest row always wins on the backend.
|
|
264
|
+
* Record a custom product event against the current session.
|
|
652
265
|
*
|
|
653
|
-
*
|
|
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.
|
|
654
272
|
*/
|
|
655
|
-
function
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
maskAllText: options.maskAllText
|
|
662
|
-
};
|
|
663
|
-
const session = getSession();
|
|
664
|
-
let currentSessionId = session.id;
|
|
665
|
-
let currentStartedAt = new Date(session.startedAt);
|
|
666
|
-
publishSessionSink(currentSessionId);
|
|
667
|
-
let recorder;
|
|
668
|
-
let events;
|
|
669
|
-
let stopped = false;
|
|
670
|
-
const postMeta = (status, clickCount, keepalive = false) => postSessionMeta(engineConfig, buildSessionMetaRow({
|
|
671
|
-
sessionId: currentSessionId,
|
|
672
|
-
startedAt: currentStartedAt,
|
|
673
|
-
version: nextMetaVersion(),
|
|
674
|
-
status,
|
|
675
|
-
serviceName: options.serviceName,
|
|
676
|
-
userId: options.getUserId?.(),
|
|
677
|
-
environment: options.environment,
|
|
678
|
-
serviceVersion: options.serviceVersion,
|
|
679
|
-
clickCount: clickCount ?? 0,
|
|
680
|
-
traceIds: status === "ended" ? getObservedTraceIds() : void 0
|
|
681
|
-
}), keepalive);
|
|
682
|
-
const start = () => {
|
|
683
|
-
recorder = startRecording(engineConfig, currentSessionId);
|
|
684
|
-
events = startEventCapture(engineConfig, currentSessionId);
|
|
685
|
-
postMeta("active", null);
|
|
686
|
-
};
|
|
687
|
-
const suspend = () => {
|
|
688
|
-
if (!recorder || !events) return;
|
|
689
|
-
recorder.flush(true);
|
|
690
|
-
events.flush(true);
|
|
691
|
-
postMeta("ended", recorder.getClickCount(), true);
|
|
692
|
-
recorder.stop();
|
|
693
|
-
events.stop();
|
|
694
|
-
recorder = void 0;
|
|
695
|
-
events = void 0;
|
|
696
|
-
};
|
|
697
|
-
const resume = () => {
|
|
698
|
-
if (stopped || recorder) return;
|
|
699
|
-
const next = getSession();
|
|
700
|
-
if (next.id !== currentSessionId) {
|
|
701
|
-
currentSessionId = next.id;
|
|
702
|
-
currentStartedAt = new Date(next.startedAt);
|
|
703
|
-
publishSessionSink(currentSessionId);
|
|
704
|
-
}
|
|
705
|
-
start();
|
|
706
|
-
};
|
|
707
|
-
const onVisibilityChange = () => {
|
|
708
|
-
if (document.visibilityState === "hidden") suspend();
|
|
709
|
-
else resume();
|
|
710
|
-
};
|
|
711
|
-
const onPageHide = () => suspend();
|
|
712
|
-
start();
|
|
713
|
-
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
714
|
-
window.addEventListener("pagehide", onPageHide);
|
|
715
|
-
return {
|
|
716
|
-
sessionId: currentSessionId,
|
|
717
|
-
shutdown: async () => {
|
|
718
|
-
stopped = true;
|
|
719
|
-
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
720
|
-
window.removeEventListener("pagehide", onPageHide);
|
|
721
|
-
if (recorder) await recorder.flush(true);
|
|
722
|
-
if (events) await events.flush(true);
|
|
723
|
-
recorder?.stop();
|
|
724
|
-
events?.stop();
|
|
725
|
-
recorder = void 0;
|
|
726
|
-
events = void 0;
|
|
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.");
|
|
727
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
|
|
728
288
|
};
|
|
289
|
+
const sink = getActiveSink();
|
|
290
|
+
if (sink) sink.emit(ev);
|
|
291
|
+
else queuePending(ev);
|
|
729
292
|
}
|
|
730
293
|
//#endregion
|
|
731
294
|
//#region src/config.ts
|
|
732
295
|
const DEFAULT_ENDPOINT = "https://ingest.maple.dev";
|
|
733
|
-
|
|
734
|
-
|
|
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);
|
|
735
302
|
}
|
|
736
303
|
function resolveConfig(config) {
|
|
737
304
|
return {
|
|
@@ -741,13 +308,19 @@ function resolveConfig(config) {
|
|
|
741
308
|
serviceNamespace: config.serviceNamespace,
|
|
742
309
|
serviceVersion: config.serviceVersion,
|
|
743
310
|
environment: config.environment,
|
|
744
|
-
|
|
311
|
+
identity: resolveIdentity(config),
|
|
745
312
|
tracingEnabled: config.tracing?.enabled ?? true,
|
|
746
313
|
tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
|
|
747
314
|
replayEnabled: config.replay?.enabled ?? true,
|
|
748
315
|
replaySampleRate: config.replay?.sampleRate ?? 1,
|
|
749
316
|
maskAllInputs: config.privacy?.maskAllInputs ?? true,
|
|
750
|
-
maskAllText: config.privacy?.maskAllText ?? false
|
|
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
|
|
751
324
|
};
|
|
752
325
|
}
|
|
753
326
|
//#endregion
|
|
@@ -757,11 +330,15 @@ function resolveConfig(config) {
|
|
|
757
330
|
* alongside the BatchSpanProcessor, does no export of its own.
|
|
758
331
|
*/
|
|
759
332
|
var TraceIdCollector = class {
|
|
333
|
+
getUserId;
|
|
760
334
|
constructor(getUserId = () => void 0) {
|
|
761
335
|
this.getUserId = getUserId;
|
|
762
336
|
}
|
|
763
337
|
onStart(span) {
|
|
338
|
+
if (!hasConsent()) return;
|
|
764
339
|
recordTraceId(span.spanContext().traceId);
|
|
340
|
+
const sessionId = readSessionSink()?.sessionId;
|
|
341
|
+
if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
|
|
765
342
|
const userId = this.getUserId();
|
|
766
343
|
if (userId !== void 0) span.setAttribute("user.id", userId);
|
|
767
344
|
}
|
|
@@ -774,18 +351,53 @@ var TraceIdCollector = class {
|
|
|
774
351
|
}
|
|
775
352
|
};
|
|
776
353
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
354
|
+
* Drops buffered spans captured while consent was absent. Checking start time,
|
|
355
|
+
* rather than only permission at flush time, also prevents a late grant from
|
|
356
|
+
* releasing spans that began before that grant.
|
|
357
|
+
*/
|
|
358
|
+
var ConsentSpanExporter = class {
|
|
359
|
+
inner;
|
|
360
|
+
constructor(inner) {
|
|
361
|
+
this.inner = inner;
|
|
362
|
+
}
|
|
363
|
+
export(spans, callback) {
|
|
364
|
+
const since = consentAllowedSince();
|
|
365
|
+
if (!hasConsent() || !Number.isFinite(since)) {
|
|
366
|
+
callback({ code: 0 });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const eligible = spans.filter((span) => span.startTime[0] * 1e3 + span.startTime[1] / 1e6 >= since);
|
|
370
|
+
if (eligible.length === 0) {
|
|
371
|
+
callback({ code: 0 });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.inner.export(eligible, callback);
|
|
375
|
+
}
|
|
376
|
+
forceFlush() {
|
|
377
|
+
return this.inner.forceFlush?.() ?? Promise.resolve();
|
|
378
|
+
}
|
|
379
|
+
shutdown() {
|
|
380
|
+
return this.inner.shutdown();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* Set up browser OTel tracing exporting to Maple's ingest. When
|
|
385
|
+
* `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
|
|
386
|
+
* their trace ids feed the session. Disable it when an external tracer (e.g.
|
|
387
|
+
* the Effect client SDK) already instruments requests — that tracer feeds the
|
|
388
|
+
* session via the published sink instead, and this avoids redundant duplicate
|
|
389
|
+
* network spans. Returns a shutdown function.
|
|
390
|
+
*
|
|
391
|
+
* `session.id` is deliberately **not** a resource attribute: the resource is
|
|
392
|
+
* fixed for the provider's lifetime, but sessions rotate under it (idle
|
|
393
|
+
* rotation, consent revoke→re-grant), so a resource-level id would attribute
|
|
394
|
+
* every post-rotation span to the ended session. `TraceIdCollector` stamps the
|
|
395
|
+
* live id per span instead.
|
|
783
396
|
*/
|
|
784
|
-
function setupTracing(config
|
|
397
|
+
function setupTracing(config) {
|
|
785
398
|
const attributes = {
|
|
786
399
|
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
787
|
-
"maple.sdk.type": "browser"
|
|
788
|
-
"session.id": sessionId
|
|
400
|
+
"maple.sdk.type": "browser"
|
|
789
401
|
};
|
|
790
402
|
if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
|
|
791
403
|
if (config.serviceVersion) {
|
|
@@ -796,17 +408,20 @@ function setupTracing(config, sessionId) {
|
|
|
796
408
|
attributes["deployment.environment"] = config.environment;
|
|
797
409
|
attributes["deployment.environment.name"] = config.environment;
|
|
798
410
|
}
|
|
799
|
-
const exporter = new OTLPTraceExporter({
|
|
411
|
+
const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
|
|
800
412
|
url: `${config.endpoint}/v1/traces`,
|
|
801
413
|
headers: { Authorization: `Bearer ${config.ingestKey}` }
|
|
802
|
-
});
|
|
414
|
+
}));
|
|
803
415
|
const provider = new WebTracerProvider({
|
|
804
416
|
resource: resourceFromAttributes(attributes),
|
|
805
|
-
spanProcessors: [new TraceIdCollector(() => config.
|
|
417
|
+
spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter)]
|
|
806
418
|
});
|
|
807
419
|
provider.register();
|
|
808
|
-
|
|
809
|
-
return () =>
|
|
420
|
+
const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
|
|
421
|
+
return async () => {
|
|
422
|
+
unregisterInstrumentations?.();
|
|
423
|
+
await provider.shutdown();
|
|
424
|
+
};
|
|
810
425
|
}
|
|
811
426
|
function escapeRegExp(value) {
|
|
812
427
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -816,13 +431,9 @@ function escapeRegExp(value) {
|
|
|
816
431
|
let active;
|
|
817
432
|
let activeConfig;
|
|
818
433
|
/**
|
|
819
|
-
* Initialize Maple browser telemetry
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* The replay lifecycle (suspend on tab-hidden, resume on visible, session
|
|
824
|
-
* metadata rows) lives in `@maple/browser-session` and is shared with the
|
|
825
|
-
* Effect client SDK's `replay` option.
|
|
434
|
+
* Initialize Maple browser telemetry. With consent gating enabled the returned
|
|
435
|
+
* handle remains live while denied: granting starts capture, revoking detaches
|
|
436
|
+
* it without flushing, and a later grant starts cleanly again.
|
|
826
437
|
*/
|
|
827
438
|
function init(rawConfig) {
|
|
828
439
|
if (active) return active;
|
|
@@ -832,28 +443,112 @@ function init(rawConfig) {
|
|
|
832
443
|
};
|
|
833
444
|
const config = resolveConfig(rawConfig);
|
|
834
445
|
activeConfig = config;
|
|
835
|
-
|
|
836
|
-
|
|
446
|
+
configurePrivacy(config);
|
|
447
|
+
if (!hasConsent()) clearPendingEvents();
|
|
837
448
|
setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
|
|
838
|
-
const shutdownTracing = config.tracingEnabled ? setupTracing(config, session.id) : void 0;
|
|
839
449
|
const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
|
|
840
|
-
let
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
450
|
+
let runtime;
|
|
451
|
+
let stopped = false;
|
|
452
|
+
let rotateOnNextStart = false;
|
|
453
|
+
let shutdownTracing;
|
|
454
|
+
let generation = 0;
|
|
455
|
+
const startRuntime = () => {
|
|
456
|
+
if (stopped || runtime || !hasConsent()) return;
|
|
457
|
+
setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
|
|
458
|
+
const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
|
|
459
|
+
rotateOnNextStart = false;
|
|
460
|
+
publishSessionSink(session.id);
|
|
461
|
+
const sink = startEventSink({
|
|
462
|
+
endpoint: config.endpoint,
|
|
463
|
+
ingestKey: config.ingestKey,
|
|
464
|
+
maskAllInputs: config.maskAllInputs,
|
|
465
|
+
maskAllText: config.maskAllText
|
|
466
|
+
}, session.id);
|
|
467
|
+
if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
|
|
468
|
+
const shared = {
|
|
469
|
+
endpoint: config.endpoint,
|
|
470
|
+
ingestKey: config.ingestKey,
|
|
471
|
+
serviceName: config.serviceName,
|
|
472
|
+
environment: config.environment,
|
|
473
|
+
serviceVersion: config.serviceVersion,
|
|
474
|
+
getIdentity: () => activeConfig?.identity,
|
|
475
|
+
captureUserEmail: config.captureUserEmail
|
|
476
|
+
};
|
|
477
|
+
const startMetadata = () => startMetadataSession({
|
|
478
|
+
...shared,
|
|
479
|
+
getTraceIds: getObservedTraceIds,
|
|
480
|
+
onSessionChange: publishSessionSink
|
|
481
|
+
});
|
|
482
|
+
if (!recordReplay) {
|
|
483
|
+
runtime = {
|
|
484
|
+
initialSessionId: session.id,
|
|
485
|
+
sink,
|
|
486
|
+
metadata: startMetadata()
|
|
487
|
+
};
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const next = {
|
|
491
|
+
initialSessionId: session.id,
|
|
492
|
+
sink
|
|
493
|
+
};
|
|
494
|
+
runtime = next;
|
|
495
|
+
const ownGeneration = ++generation;
|
|
496
|
+
const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
|
|
497
|
+
next.replayPending = import("./replay-session-B_ACRogX.mjs").then(({ startReplaySession }) => {
|
|
498
|
+
if (stale()) return;
|
|
499
|
+
next.replay = startReplaySession({
|
|
500
|
+
...shared,
|
|
501
|
+
maskAllInputs: config.maskAllInputs,
|
|
502
|
+
maskAllText: config.maskAllText
|
|
503
|
+
});
|
|
504
|
+
}).catch(() => {
|
|
505
|
+
if (stale()) return;
|
|
506
|
+
next.metadata = startMetadata();
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
const stopRuntime = async (flush) => {
|
|
510
|
+
generation++;
|
|
511
|
+
const previous = runtime;
|
|
512
|
+
runtime = void 0;
|
|
513
|
+
if (!previous) return;
|
|
514
|
+
const replayShutdown = previous.replay?.shutdown({ flush });
|
|
515
|
+
const metadataShutdown = previous.metadata?.shutdown({ flush });
|
|
516
|
+
const currentSessionId = previous.replay?.sessionId ?? previous.metadata?.sessionId;
|
|
517
|
+
const liveSink = getActiveSink();
|
|
518
|
+
const sink = liveSink && currentSessionId && liveSink.sessionId === currentSessionId ? liveSink : previous.sink;
|
|
519
|
+
if (flush) await sink.flush(true);
|
|
520
|
+
sink.stop();
|
|
521
|
+
if (sink !== previous.sink) previous.sink.stop();
|
|
522
|
+
clearSessionSink(currentSessionId ?? previous.initialSessionId);
|
|
523
|
+
await Promise.all([
|
|
524
|
+
replayShutdown,
|
|
525
|
+
metadataShutdown,
|
|
526
|
+
previous.replayPending
|
|
527
|
+
]);
|
|
528
|
+
};
|
|
529
|
+
startRuntime();
|
|
530
|
+
const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => {
|
|
531
|
+
if (allowed) {
|
|
532
|
+
startRuntime();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
rotateOnNextStart = runtime !== void 0;
|
|
536
|
+
clearPendingEvents();
|
|
537
|
+
setVisitorTracking(false);
|
|
538
|
+
stopRuntime(false);
|
|
539
|
+
}) : () => {};
|
|
851
540
|
const handle = {
|
|
852
|
-
sessionId
|
|
541
|
+
get sessionId() {
|
|
542
|
+
return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
|
|
543
|
+
},
|
|
853
544
|
shutdown: async () => {
|
|
854
|
-
|
|
855
|
-
|
|
545
|
+
if (stopped) return;
|
|
546
|
+
stopped = true;
|
|
547
|
+
stopConsentListener();
|
|
548
|
+
await stopRuntime(true);
|
|
856
549
|
await shutdownTracing?.();
|
|
550
|
+
shutdownTracing = void 0;
|
|
551
|
+
setActiveTraceIdProvider(() => void 0);
|
|
857
552
|
active = void 0;
|
|
858
553
|
activeConfig = void 0;
|
|
859
554
|
}
|
|
@@ -862,14 +557,24 @@ function init(rawConfig) {
|
|
|
862
557
|
return handle;
|
|
863
558
|
}
|
|
864
559
|
/**
|
|
865
|
-
* Attach, replace, or clear the user
|
|
866
|
-
* safe to call on every render. Future browser-created spans
|
|
867
|
-
* when they start, and future session metadata rows read
|
|
560
|
+
* Attach, replace, or clear the end-user identity on the active session.
|
|
561
|
+
* Idempotent and safe to call on every render. Future browser-created spans
|
|
562
|
+
* read the id when they start, and future session metadata rows read the whole
|
|
563
|
+
* identity when they post.
|
|
564
|
+
*
|
|
565
|
+
* Accepts a bare user id or the full object:
|
|
566
|
+
*
|
|
567
|
+
* ```ts
|
|
568
|
+
* MapleBrowser.identify("user_123")
|
|
569
|
+
* MapleBrowser.identify({ id: "user_123", email: "a@b.com", groupId: "org_1", groupName: "Acme" })
|
|
570
|
+
* ```
|
|
571
|
+
*
|
|
572
|
+
* Each call replaces the identity rather than merging — merging would leak a
|
|
573
|
+
* signed-out user's email into whoever signs in next on a shared device.
|
|
868
574
|
*/
|
|
869
|
-
function identify(
|
|
870
|
-
if (typeof window === "undefined") return;
|
|
871
|
-
|
|
872
|
-
activeConfig.userId = normalizeUserId(userId);
|
|
575
|
+
function identify(input) {
|
|
576
|
+
if (typeof window === "undefined" || !activeConfig) return;
|
|
577
|
+
activeConfig.identity = normalizeIdentity(input);
|
|
873
578
|
}
|
|
874
579
|
//#endregion
|
|
875
580
|
//#region src/index.ts
|
|
@@ -885,11 +590,16 @@ function identify(userId) {
|
|
|
885
590
|
* ingestKey: "maple_pk_...",
|
|
886
591
|
* serviceName: "acme-web",
|
|
887
592
|
* })
|
|
593
|
+
*
|
|
594
|
+
* MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
|
|
595
|
+
* MapleBrowser.track("checkout_completed", { plan: "pro", seats: 5 })
|
|
888
596
|
* ```
|
|
889
597
|
*/
|
|
890
598
|
const MapleBrowser = {
|
|
891
599
|
init,
|
|
892
|
-
identify
|
|
600
|
+
identify,
|
|
601
|
+
track,
|
|
602
|
+
setConsent
|
|
893
603
|
};
|
|
894
604
|
//#endregion
|
|
895
605
|
export { MapleBrowser };
|