@maple-dev/browser 0.3.0 → 0.8.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 +54 -1
- package/dist/index.d.mts +92 -8
- package/dist/index.mjs +474 -728
- package/dist/replay-session-BYZfi3iZ.mjs +327 -0
- package/dist/sink-D9w1kg0Q.mjs +1424 -0
- package/package.json +19 -4
package/dist/index.mjs
CHANGED
|
@@ -1,737 +1,304 @@
|
|
|
1
|
+
import { C as setActiveTraceIdProvider, T as setVisitorTracking, a as recordTraceId, b as sdkHint, c as getActiveSink, d as getSession, g as SDK_HINT_HEADER, h as postSessionMetaRow, i as readSessionSink, l as queuePending, m as rotateSession, n as getObservedTraceIds, o as startSessionLifecycle, r as publishSessionSink, s as clearPendingEvents, t as clearSessionSink, u as startEventSink, w as configureVisitorCookie } from "./sink-D9w1kg0Q.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/identity/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/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
|
-
});
|
|
107
|
+
function consentAllowedSince() {
|
|
108
|
+
return hasConsent() ? consentState().allowedSince : Number.POSITIVE_INFINITY;
|
|
230
109
|
}
|
|
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/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/session/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, 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/events/track.ts
|
|
213
|
+
/**
|
|
214
|
+
* Caps mirroring what the ingest gateway enforces. Applying them here too means
|
|
215
|
+
* an over-sized event is trimmed before it costs bandwidth, and the developer
|
|
216
|
+
* sees the same shape locally that the warehouse will store.
|
|
217
|
+
*/
|
|
218
|
+
const MAX_NAME_LENGTH = 128;
|
|
219
|
+
const MAX_PROPS = 32;
|
|
220
|
+
const MAX_PROP_KEY_LENGTH = 64;
|
|
221
|
+
const MAX_PROP_VALUE_LENGTH = 1024;
|
|
222
|
+
const MAX_TOTAL_PROP_BYTES = 8192;
|
|
223
|
+
let warnedAboutName = false;
|
|
224
|
+
/**
|
|
225
|
+
* Coerce one property value to the string the warehouse column holds.
|
|
226
|
+
*
|
|
227
|
+
* `null`/`undefined`/functions/symbols are dropped rather than stringified —
|
|
228
|
+
* `"undefined"` as a stored value is worse than an absent key.
|
|
229
|
+
*/
|
|
230
|
+
function coerce(value) {
|
|
231
|
+
if (value === null || value === void 0) return void 0;
|
|
232
|
+
switch (typeof value) {
|
|
233
|
+
case "string": return value.slice(0, MAX_PROP_VALUE_LENGTH);
|
|
234
|
+
case "number":
|
|
235
|
+
case "boolean":
|
|
236
|
+
case "bigint": return String(value);
|
|
237
|
+
case "function":
|
|
238
|
+
case "symbol": return;
|
|
239
|
+
}
|
|
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,27 +308,42 @@ 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
|
|
327
|
+
//#region src/version.ts
|
|
328
|
+
const SDK_VERSION = "0.8.0";
|
|
329
|
+
/** The `x-maple-sdk` value this build sends. */
|
|
330
|
+
const SDK_NAME = "maple-browser";
|
|
331
|
+
//#endregion
|
|
754
332
|
//#region src/tracing.ts
|
|
755
333
|
/**
|
|
756
334
|
* Captures every span's trace id into the session sink. Lightweight — runs
|
|
757
335
|
* alongside the BatchSpanProcessor, does no export of its own.
|
|
758
336
|
*/
|
|
759
337
|
var TraceIdCollector = class {
|
|
338
|
+
getUserId;
|
|
760
339
|
constructor(getUserId = () => void 0) {
|
|
761
340
|
this.getUserId = getUserId;
|
|
762
341
|
}
|
|
763
342
|
onStart(span) {
|
|
343
|
+
if (!hasConsent()) return;
|
|
764
344
|
recordTraceId(span.spanContext().traceId);
|
|
345
|
+
const sessionId = readSessionSink()?.sessionId;
|
|
346
|
+
if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
|
|
765
347
|
const userId = this.getUserId();
|
|
766
348
|
if (userId !== void 0) span.setAttribute("user.id", userId);
|
|
767
349
|
}
|
|
@@ -774,18 +356,62 @@ var TraceIdCollector = class {
|
|
|
774
356
|
}
|
|
775
357
|
};
|
|
776
358
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
* when an external tracer (e.g. the Effect client SDK) already instruments
|
|
781
|
-
* requests — that tracer feeds the session via the published sink instead, and
|
|
782
|
-
* this avoids redundant duplicate network spans. Returns a shutdown function.
|
|
359
|
+
* Drops buffered spans captured while consent was absent. Checking start time,
|
|
360
|
+
* rather than only permission at flush time, also prevents a late grant from
|
|
361
|
+
* releasing spans that began before that grant.
|
|
783
362
|
*/
|
|
784
|
-
|
|
363
|
+
var ConsentSpanExporter = class {
|
|
364
|
+
inner;
|
|
365
|
+
constructor(inner) {
|
|
366
|
+
this.inner = inner;
|
|
367
|
+
}
|
|
368
|
+
export(spans, callback) {
|
|
369
|
+
const since = consentAllowedSince();
|
|
370
|
+
if (!hasConsent() || !Number.isFinite(since)) {
|
|
371
|
+
callback({ code: 0 });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const eligible = spans.filter((span) => span.startTime[0] * 1e3 + span.startTime[1] / 1e6 >= since);
|
|
375
|
+
if (eligible.length === 0) {
|
|
376
|
+
callback({ code: 0 });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this.inner.export(eligible, callback);
|
|
380
|
+
}
|
|
381
|
+
forceFlush() {
|
|
382
|
+
return this.inner.forceFlush?.() ?? Promise.resolve();
|
|
383
|
+
}
|
|
384
|
+
shutdown() {
|
|
385
|
+
return this.inner.shutdown();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
/**
|
|
389
|
+
* How long a span may sit in the batch queue before export.
|
|
390
|
+
*
|
|
391
|
+
* Shorter than OTel's 5s default: in a browser the queue is only as durable as
|
|
392
|
+
* the tab, and the unload flush below is a best-effort catch rather than a
|
|
393
|
+
* guarantee (a crashed or killed tab fires neither event). 2s trades a few more
|
|
394
|
+
* requests for a materially smaller loss window.
|
|
395
|
+
*/
|
|
396
|
+
const EXPORT_INTERVAL_MS = 2e3;
|
|
397
|
+
/**
|
|
398
|
+
* Set up browser OTel tracing exporting to Maple's ingest. When
|
|
399
|
+
* `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and
|
|
400
|
+
* their trace ids feed the session. Disable it when an external tracer (e.g.
|
|
401
|
+
* the Effect client SDK) already instruments requests — that tracer feeds the
|
|
402
|
+
* session via the published sink instead, and this avoids redundant duplicate
|
|
403
|
+
* network spans. Returns a shutdown function.
|
|
404
|
+
*
|
|
405
|
+
* `session.id` is deliberately **not** a resource attribute: the resource is
|
|
406
|
+
* fixed for the provider's lifetime, but sessions rotate under it (idle
|
|
407
|
+
* rotation, consent revoke→re-grant), so a resource-level id would attribute
|
|
408
|
+
* every post-rotation span to the ended session. `TraceIdCollector` stamps the
|
|
409
|
+
* live id per span instead.
|
|
410
|
+
*/
|
|
411
|
+
function setupTracing(config) {
|
|
785
412
|
const attributes = {
|
|
786
413
|
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
787
|
-
"maple.sdk.type": "browser"
|
|
788
|
-
"session.id": sessionId
|
|
414
|
+
"maple.sdk.type": "browser"
|
|
789
415
|
};
|
|
790
416
|
if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
|
|
791
417
|
if (config.serviceVersion) {
|
|
@@ -796,33 +422,52 @@ function setupTracing(config, sessionId) {
|
|
|
796
422
|
attributes["deployment.environment"] = config.environment;
|
|
797
423
|
attributes["deployment.environment.name"] = config.environment;
|
|
798
424
|
}
|
|
799
|
-
const exporter = new OTLPTraceExporter({
|
|
425
|
+
const exporter = new ConsentSpanExporter(new OTLPTraceExporter({
|
|
800
426
|
url: `${config.endpoint}/v1/traces`,
|
|
801
|
-
headers: {
|
|
802
|
-
|
|
427
|
+
headers: {
|
|
428
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
429
|
+
[SDK_HINT_HEADER]: sdkHint(SDK_NAME, SDK_VERSION)
|
|
430
|
+
}
|
|
431
|
+
}));
|
|
803
432
|
const provider = new WebTracerProvider({
|
|
804
433
|
resource: resourceFromAttributes(attributes),
|
|
805
|
-
spanProcessors: [new TraceIdCollector(() => config.
|
|
434
|
+
spanProcessors: [new TraceIdCollector(() => config.identity?.id), new BatchSpanProcessor(exporter, { scheduledDelayMillis: EXPORT_INTERVAL_MS })]
|
|
806
435
|
});
|
|
807
436
|
provider.register();
|
|
808
|
-
|
|
809
|
-
|
|
437
|
+
const onExit = () => {
|
|
438
|
+
provider.forceFlush().catch(() => {});
|
|
439
|
+
};
|
|
440
|
+
const onVisibilityChange = () => {
|
|
441
|
+
if (document.visibilityState === "hidden") onExit();
|
|
442
|
+
};
|
|
443
|
+
const canListen = typeof document !== "undefined" && typeof document.addEventListener === "function";
|
|
444
|
+
if (canListen) {
|
|
445
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
446
|
+
window.addEventListener("pagehide", onExit);
|
|
447
|
+
}
|
|
448
|
+
const unregisterInstrumentations = config.tracingInstrumentFetch ? registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] }) : void 0;
|
|
449
|
+
return async () => {
|
|
450
|
+
if (canListen) {
|
|
451
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
452
|
+
window.removeEventListener("pagehide", onExit);
|
|
453
|
+
}
|
|
454
|
+
unregisterInstrumentations?.();
|
|
455
|
+
await provider.shutdown();
|
|
456
|
+
};
|
|
810
457
|
}
|
|
811
458
|
function escapeRegExp(value) {
|
|
812
459
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
813
460
|
}
|
|
814
461
|
//#endregion
|
|
815
462
|
//#region src/init.ts
|
|
463
|
+
/** `x-maple-sdk` value for every request this build makes to ingest. */
|
|
464
|
+
const SDK_HINT = sdkHint(SDK_NAME, SDK_VERSION);
|
|
816
465
|
let active;
|
|
817
466
|
let activeConfig;
|
|
818
467
|
/**
|
|
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.
|
|
468
|
+
* Initialize Maple browser telemetry. With consent gating enabled the returned
|
|
469
|
+
* handle remains live while denied: granting starts capture, revoking detaches
|
|
470
|
+
* it without flushing, and a later grant starts cleanly again.
|
|
826
471
|
*/
|
|
827
472
|
function init(rawConfig) {
|
|
828
473
|
if (active) return active;
|
|
@@ -832,28 +477,114 @@ function init(rawConfig) {
|
|
|
832
477
|
};
|
|
833
478
|
const config = resolveConfig(rawConfig);
|
|
834
479
|
activeConfig = config;
|
|
835
|
-
|
|
836
|
-
|
|
480
|
+
configurePrivacy(config);
|
|
481
|
+
if (!hasConsent()) clearPendingEvents();
|
|
837
482
|
setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId);
|
|
838
|
-
const shutdownTracing = config.tracingEnabled ? setupTracing(config, session.id) : void 0;
|
|
839
483
|
const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
|
|
840
|
-
let
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
484
|
+
let runtime;
|
|
485
|
+
let stopped = false;
|
|
486
|
+
let rotateOnNextStart = false;
|
|
487
|
+
let shutdownTracing;
|
|
488
|
+
let generation = 0;
|
|
489
|
+
const startRuntime = () => {
|
|
490
|
+
if (stopped || runtime || !hasConsent()) return;
|
|
491
|
+
setVisitorTracking(config.persistVisitorId && mayPersistIdentifier());
|
|
492
|
+
const session = (rotateOnNextStart ? rotateSession() : void 0) ?? getSession();
|
|
493
|
+
rotateOnNextStart = false;
|
|
494
|
+
publishSessionSink(session.id);
|
|
495
|
+
const sink = startEventSink({
|
|
496
|
+
endpoint: config.endpoint,
|
|
497
|
+
ingestKey: config.ingestKey,
|
|
498
|
+
sdk: SDK_HINT,
|
|
499
|
+
maskAllInputs: config.maskAllInputs,
|
|
500
|
+
maskAllText: config.maskAllText
|
|
501
|
+
}, session.id);
|
|
502
|
+
if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config);
|
|
503
|
+
const shared = {
|
|
504
|
+
endpoint: config.endpoint,
|
|
505
|
+
ingestKey: config.ingestKey,
|
|
506
|
+
sdk: SDK_HINT,
|
|
507
|
+
serviceName: config.serviceName,
|
|
508
|
+
environment: config.environment,
|
|
509
|
+
serviceVersion: config.serviceVersion,
|
|
510
|
+
getIdentity: () => activeConfig?.identity,
|
|
511
|
+
captureUserEmail: config.captureUserEmail
|
|
512
|
+
};
|
|
513
|
+
const startMetadata = () => startMetadataSession({
|
|
514
|
+
...shared,
|
|
515
|
+
getTraceIds: getObservedTraceIds,
|
|
516
|
+
onSessionChange: publishSessionSink
|
|
517
|
+
});
|
|
518
|
+
if (!recordReplay) {
|
|
519
|
+
runtime = {
|
|
520
|
+
initialSessionId: session.id,
|
|
521
|
+
sink,
|
|
522
|
+
metadata: startMetadata()
|
|
523
|
+
};
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const next = {
|
|
527
|
+
initialSessionId: session.id,
|
|
528
|
+
sink
|
|
529
|
+
};
|
|
530
|
+
runtime = next;
|
|
531
|
+
const ownGeneration = ++generation;
|
|
532
|
+
const stale = () => stopped || !hasConsent() || generation !== ownGeneration || runtime !== next;
|
|
533
|
+
next.replayPending = import("./replay-session-BYZfi3iZ.mjs").then(({ startReplaySession }) => {
|
|
534
|
+
if (stale()) return;
|
|
535
|
+
next.replay = startReplaySession({
|
|
536
|
+
...shared,
|
|
537
|
+
maskAllInputs: config.maskAllInputs,
|
|
538
|
+
maskAllText: config.maskAllText
|
|
539
|
+
});
|
|
540
|
+
}).catch(() => {
|
|
541
|
+
if (stale()) return;
|
|
542
|
+
next.metadata = startMetadata();
|
|
543
|
+
});
|
|
544
|
+
};
|
|
545
|
+
const stopRuntime = async (flush) => {
|
|
546
|
+
generation++;
|
|
547
|
+
const previous = runtime;
|
|
548
|
+
runtime = void 0;
|
|
549
|
+
if (!previous) return;
|
|
550
|
+
const replayShutdown = previous.replay?.shutdown({ flush });
|
|
551
|
+
const metadataShutdown = previous.metadata?.shutdown({ flush });
|
|
552
|
+
const currentSessionId = previous.replay?.sessionId ?? previous.metadata?.sessionId;
|
|
553
|
+
const liveSink = getActiveSink();
|
|
554
|
+
const sink = liveSink && currentSessionId && liveSink.sessionId === currentSessionId ? liveSink : previous.sink;
|
|
555
|
+
if (flush) await sink.flush(true);
|
|
556
|
+
sink.stop();
|
|
557
|
+
if (sink !== previous.sink) previous.sink.stop();
|
|
558
|
+
clearSessionSink(currentSessionId ?? previous.initialSessionId);
|
|
559
|
+
await Promise.all([
|
|
560
|
+
replayShutdown,
|
|
561
|
+
metadataShutdown,
|
|
562
|
+
previous.replayPending
|
|
563
|
+
]);
|
|
564
|
+
};
|
|
565
|
+
startRuntime();
|
|
566
|
+
const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => {
|
|
567
|
+
if (allowed) {
|
|
568
|
+
startRuntime();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
rotateOnNextStart = runtime !== void 0;
|
|
572
|
+
clearPendingEvents();
|
|
573
|
+
setVisitorTracking(false);
|
|
574
|
+
stopRuntime(false);
|
|
575
|
+
}) : () => {};
|
|
851
576
|
const handle = {
|
|
852
|
-
sessionId
|
|
577
|
+
get sessionId() {
|
|
578
|
+
return runtime?.replay?.sessionId ?? runtime?.metadata?.sessionId ?? runtime?.initialSessionId ?? "";
|
|
579
|
+
},
|
|
853
580
|
shutdown: async () => {
|
|
854
|
-
|
|
855
|
-
|
|
581
|
+
if (stopped) return;
|
|
582
|
+
stopped = true;
|
|
583
|
+
stopConsentListener();
|
|
584
|
+
await stopRuntime(true);
|
|
856
585
|
await shutdownTracing?.();
|
|
586
|
+
shutdownTracing = void 0;
|
|
587
|
+
setActiveTraceIdProvider(() => void 0);
|
|
857
588
|
active = void 0;
|
|
858
589
|
activeConfig = void 0;
|
|
859
590
|
}
|
|
@@ -862,14 +593,24 @@ function init(rawConfig) {
|
|
|
862
593
|
return handle;
|
|
863
594
|
}
|
|
864
595
|
/**
|
|
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
|
|
596
|
+
* Attach, replace, or clear the end-user identity on the active session.
|
|
597
|
+
* Idempotent and safe to call on every render. Future browser-created spans
|
|
598
|
+
* read the id when they start, and future session metadata rows read the whole
|
|
599
|
+
* identity when they post.
|
|
600
|
+
*
|
|
601
|
+
* Accepts a bare user id or the full object:
|
|
602
|
+
*
|
|
603
|
+
* ```ts
|
|
604
|
+
* MapleBrowser.identify("user_123")
|
|
605
|
+
* MapleBrowser.identify({ id: "user_123", email: "a@b.com", groupId: "org_1", groupName: "Acme" })
|
|
606
|
+
* ```
|
|
607
|
+
*
|
|
608
|
+
* Each call replaces the identity rather than merging — merging would leak a
|
|
609
|
+
* signed-out user's email into whoever signs in next on a shared device.
|
|
868
610
|
*/
|
|
869
|
-
function identify(
|
|
870
|
-
if (typeof window === "undefined") return;
|
|
871
|
-
|
|
872
|
-
activeConfig.userId = normalizeUserId(userId);
|
|
611
|
+
function identify(input) {
|
|
612
|
+
if (typeof window === "undefined" || !activeConfig) return;
|
|
613
|
+
activeConfig.identity = normalizeIdentity(input);
|
|
873
614
|
}
|
|
874
615
|
//#endregion
|
|
875
616
|
//#region src/index.ts
|
|
@@ -885,11 +626,16 @@ function identify(userId) {
|
|
|
885
626
|
* ingestKey: "maple_pk_...",
|
|
886
627
|
* serviceName: "acme-web",
|
|
887
628
|
* })
|
|
629
|
+
*
|
|
630
|
+
* MapleBrowser.identify({ id: user.id, email: user.email, groupId: org.id, groupName: org.name })
|
|
631
|
+
* MapleBrowser.track("checkout_completed", { plan: "pro", seats: 5 })
|
|
888
632
|
* ```
|
|
889
633
|
*/
|
|
890
634
|
const MapleBrowser = {
|
|
891
635
|
init,
|
|
892
|
-
identify
|
|
636
|
+
identify,
|
|
637
|
+
track,
|
|
638
|
+
setConsent
|
|
893
639
|
};
|
|
894
640
|
//#endregion
|
|
895
641
|
export { MapleBrowser };
|