@maple-dev/browser 0.2.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 +49 -1
- package/dist/index.d.mts +93 -9
- package/dist/index.mjs +443 -729
- 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,735 +1,305 @@
|
|
|
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);
|
|
199
|
-
}
|
|
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 {}
|
|
107
|
+
function consentAllowedSince() {
|
|
108
|
+
return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
|
|
257
109
|
}
|
|
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
|
-
};
|
|
158
|
+
return out;
|
|
371
159
|
}
|
|
372
|
-
function
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return input.url;
|
|
160
|
+
function trimmed(value) {
|
|
161
|
+
const text = value?.trim();
|
|
162
|
+
return text ? text : void 0;
|
|
376
163
|
}
|
|
377
|
-
function requestMethod(input, init) {
|
|
378
|
-
return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
|
|
379
|
-
}
|
|
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
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
* monotonic per session, so the latest row always wins on the backend.
|
|
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.
|
|
652
269
|
*
|
|
653
|
-
*
|
|
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";
|
|
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
|
+
}
|
|
733
303
|
function resolveConfig(config) {
|
|
734
304
|
return {
|
|
735
305
|
ingestKey: config.ingestKey,
|
|
@@ -738,13 +308,19 @@ function resolveConfig(config) {
|
|
|
738
308
|
serviceNamespace: config.serviceNamespace,
|
|
739
309
|
serviceVersion: config.serviceVersion,
|
|
740
310
|
environment: config.environment,
|
|
741
|
-
|
|
311
|
+
identity: resolveIdentity(config),
|
|
742
312
|
tracingEnabled: config.tracing?.enabled ?? true,
|
|
743
313
|
tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
|
|
744
314
|
replayEnabled: config.replay?.enabled ?? true,
|
|
745
315
|
replaySampleRate: config.replay?.sampleRate ?? 1,
|
|
746
316
|
maskAllInputs: config.privacy?.maskAllInputs ?? true,
|
|
747
|
-
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
|
|
748
324
|
};
|
|
749
325
|
}
|
|
750
326
|
//#endregion
|
|
@@ -754,8 +330,17 @@ function resolveConfig(config) {
|
|
|
754
330
|
* alongside the BatchSpanProcessor, does no export of its own.
|
|
755
331
|
*/
|
|
756
332
|
var TraceIdCollector = class {
|
|
333
|
+
getUserId;
|
|
334
|
+
constructor(getUserId = () => void 0) {
|
|
335
|
+
this.getUserId = getUserId;
|
|
336
|
+
}
|
|
757
337
|
onStart(span) {
|
|
338
|
+
if (!hasConsent()) return;
|
|
758
339
|
recordTraceId(span.spanContext().traceId);
|
|
340
|
+
const sessionId = readSessionSink()?.sessionId;
|
|
341
|
+
if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
|
|
342
|
+
const userId = this.getUserId();
|
|
343
|
+
if (userId !== void 0) span.setAttribute("user.id", userId);
|
|
759
344
|
}
|
|
760
345
|
onEnd(_span) {}
|
|
761
346
|
forceFlush() {
|
|
@@ -766,18 +351,53 @@ var TraceIdCollector = class {
|
|
|
766
351
|
}
|
|
767
352
|
};
|
|
768
353
|
/**
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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.
|
|
775
396
|
*/
|
|
776
|
-
function setupTracing(config
|
|
397
|
+
function setupTracing(config) {
|
|
777
398
|
const attributes = {
|
|
778
399
|
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
779
|
-
"maple.sdk.type": "browser"
|
|
780
|
-
"session.id": sessionId
|
|
400
|
+
"maple.sdk.type": "browser"
|
|
781
401
|
};
|
|
782
402
|
if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
|
|
783
403
|
if (config.serviceVersion) {
|
|
@@ -788,17 +408,20 @@ function setupTracing(config, sessionId) {
|
|
|
788
408
|
attributes["deployment.environment"] = config.environment;
|
|
789
409
|
attributes["deployment.environment.name"] = config.environment;
|
|
790
410
|
}
|
|
791
|
-
const exporter = new OTLPTraceExporter({
|
|
411
|
+
const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
|
|
792
412
|
url: `${config.endpoint}/v1/traces`,
|
|
793
413
|
headers: { Authorization: `Bearer ${config.ingestKey}` }
|
|
794
|
-
});
|
|
414
|
+
}));
|
|
795
415
|
const provider = new WebTracerProvider({
|
|
796
416
|
resource: resourceFromAttributes(attributes),
|
|
797
|
-
spanProcessors: [new TraceIdCollector(), new BatchSpanProcessor(exporter)]
|
|
417
|
+
spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter)]
|
|
798
418
|
});
|
|
799
419
|
provider.register();
|
|
800
|
-
|
|
801
|
-
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
|
+
};
|
|
802
425
|
}
|
|
803
426
|
function escapeRegExp(value) {
|
|
804
427
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -808,13 +431,9 @@ function escapeRegExp(value) {
|
|
|
808
431
|
let active;
|
|
809
432
|
let activeConfig;
|
|
810
433
|
/**
|
|
811
|
-
* Initialize Maple browser telemetry
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
* The replay lifecycle (suspend on tab-hidden, resume on visible, session
|
|
816
|
-
* metadata rows) lives in `@maple/browser-session` and is shared with the
|
|
817
|
-
* 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.
|
|
818
437
|
*/
|
|
819
438
|
function init(rawConfig) {
|
|
820
439
|
if (active) return active;
|
|
@@ -824,28 +443,112 @@ function init(rawConfig) {
|
|
|
824
443
|
};
|
|
825
444
|
const config = resolveConfig(rawConfig);
|
|
826
445
|
activeConfig = config;
|
|
827
|
-
|
|
828
|
-
|
|
446
|
+
configurePrivacy(config);
|
|
447
|
+
if (!hasConsent()) clearPendingEvents();
|
|
829
448
|
setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
|
|
830
|
-
const shutdownTracing = config.tracingEnabled ? setupTracing(config, session.id) : void 0;
|
|
831
449
|
const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
|
|
832
|
-
let
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
+
}) : () => {};
|
|
843
540
|
const handle = {
|
|
844
|
-
sessionId
|
|
541
|
+
get sessionId() {
|
|
542
|
+
return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
|
|
543
|
+
},
|
|
845
544
|
shutdown: async () => {
|
|
846
|
-
|
|
847
|
-
|
|
545
|
+
if (stopped) return;
|
|
546
|
+
stopped = true;
|
|
547
|
+
stopConsentListener();
|
|
548
|
+
await stopRuntime(true);
|
|
848
549
|
await shutdownTracing?.();
|
|
550
|
+
shutdownTracing = void 0;
|
|
551
|
+
setActiveTraceIdProvider(() => void 0);
|
|
849
552
|
active = void 0;
|
|
850
553
|
activeConfig = void 0;
|
|
851
554
|
}
|
|
@@ -854,18 +557,24 @@ function init(rawConfig) {
|
|
|
854
557
|
return handle;
|
|
855
558
|
}
|
|
856
559
|
/**
|
|
857
|
-
* Attach
|
|
858
|
-
* call on every render.
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
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.
|
|
862
574
|
*/
|
|
863
|
-
function identify(
|
|
864
|
-
if (typeof window === "undefined") return;
|
|
865
|
-
|
|
866
|
-
if (!userId) return;
|
|
867
|
-
if (activeConfig.userId === userId) return;
|
|
868
|
-
activeConfig.userId = userId;
|
|
575
|
+
function identify(input) {
|
|
576
|
+
if (typeof window === "undefined" || !activeConfig) return;
|
|
577
|
+
activeConfig.identity = normalizeIdentity(input);
|
|
869
578
|
}
|
|
870
579
|
//#endregion
|
|
871
580
|
//#region src/index.ts
|
|
@@ -881,11 +590,16 @@ function identify(userId) {
|
|
|
881
590
|
* ingestKey: "maple_pk_...",
|
|
882
591
|
* serviceName: "acme-web",
|
|
883
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 })
|
|
884
596
|
* ```
|
|
885
597
|
*/
|
|
886
598
|
const MapleBrowser = {
|
|
887
599
|
init,
|
|
888
|
-
identify
|
|
600
|
+
identify,
|
|
601
|
+
track,
|
|
602
|
+
setConsent
|
|
889
603
|
};
|
|
890
604
|
//#endregion
|
|
891
605
|
export { MapleBrowser };
|