@maple-dev/browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/index.d.mts +67 -0
- package/dist/index.mjs +778 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @maple-dev/browser
|
|
2
|
+
|
|
3
|
+
Browser SDK for [Maple](https://maple.dev) — OpenTelemetry tracing **and** rrweb
|
|
4
|
+
session replay in a single package. Every span and every replay event is tagged
|
|
5
|
+
with the same `session.id`, so a trace can link straight to the replay that
|
|
6
|
+
produced it (and vice versa) with no clock-skew guessing.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @maple-dev/browser
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { MapleBrowser } from "@maple-dev/browser"
|
|
18
|
+
|
|
19
|
+
MapleBrowser.init({
|
|
20
|
+
ingestKey: "maple_pk_...", // public ingest key
|
|
21
|
+
serviceName: "acme-web",
|
|
22
|
+
environment: "production",
|
|
23
|
+
replay: { enabled: true, sampleRate: 1.0 },
|
|
24
|
+
privacy: { maskAllInputs: true },
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That single call:
|
|
29
|
+
|
|
30
|
+
- starts OTel browser tracing, auto-instrumenting `fetch`, exporting to Maple's
|
|
31
|
+
ingest (`POST /v1/traces`);
|
|
32
|
+
- records the session with rrweb, chunking events (~5s / 100KB windows),
|
|
33
|
+
gzipping them with the native `CompressionStream`, and uploading to
|
|
34
|
+
`POST /v1/sessionReplays/blob`;
|
|
35
|
+
- writes session metadata at start (`active`) and on page hide (`ended`),
|
|
36
|
+
including the trace ids observed during the session.
|
|
37
|
+
|
|
38
|
+
## Privacy
|
|
39
|
+
|
|
40
|
+
`maskAllInputs` (default **on**) masks every `<input>` value. Use rrweb's
|
|
41
|
+
attribute hooks (`data-rr-block`, `.rr-block`, `.rr-ignore`) to block elements
|
|
42
|
+
or subtrees from capture.
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
- Replay event blobs live in object storage; only small, queryable metadata is
|
|
47
|
+
indexed — playback streams blobs directly via signed URLs.
|
|
48
|
+
- The SDK is best-effort: network failures in telemetry never throw into your
|
|
49
|
+
app.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
/** Public configuration for `MapleBrowser.init`. */
|
|
3
|
+
interface MapleBrowserConfig {
|
|
4
|
+
/** Public ingest key (`maple_pk_...`). */
|
|
5
|
+
readonly ingestKey: string;
|
|
6
|
+
/** Service name reported on traces and stored on replay sessions. */
|
|
7
|
+
readonly serviceName: string;
|
|
8
|
+
/** Maple ingest base URL. Defaults to `https://ingest.maple.dev`. */
|
|
9
|
+
readonly endpoint?: string;
|
|
10
|
+
/** Service version / commit SHA. */
|
|
11
|
+
readonly serviceVersion?: string;
|
|
12
|
+
/** Deployment environment, e.g. "production". */
|
|
13
|
+
readonly environment?: string;
|
|
14
|
+
/** Optional user id attached to the replay session. */
|
|
15
|
+
readonly userId?: string;
|
|
16
|
+
readonly tracing?: {
|
|
17
|
+
/** Default true. */readonly enabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Auto-instrument `fetch()` to create network spans. Default true. Set
|
|
20
|
+
* false when another tracer (e.g. the Effect client SDK) already
|
|
21
|
+
* instruments requests — those spans feed the session via the published
|
|
22
|
+
* sink, and disabling this avoids redundant duplicate network spans.
|
|
23
|
+
*/
|
|
24
|
+
readonly instrumentFetch?: boolean;
|
|
25
|
+
};
|
|
26
|
+
readonly replay?: {
|
|
27
|
+
/** Default true. */readonly enabled?: boolean; /** Fraction of sessions to record, 0–1. Default 1. */
|
|
28
|
+
readonly sampleRate?: number;
|
|
29
|
+
};
|
|
30
|
+
readonly privacy?: {
|
|
31
|
+
/** Mask all `<input>` values. Default true. */readonly maskAllInputs?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Mask all text in the rrweb recording and omit captured click target
|
|
34
|
+
* text from session events. Default false.
|
|
35
|
+
*/
|
|
36
|
+
readonly maskAllText?: boolean;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/init.d.ts
|
|
41
|
+
interface MapleBrowserHandle {
|
|
42
|
+
readonly sessionId: string;
|
|
43
|
+
/** Tear down tracing + replay (flushing the final chunk). */
|
|
44
|
+
readonly shutdown: () => Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/index.d.ts
|
|
48
|
+
/**
|
|
49
|
+
* Maple browser SDK. One call wires up OpenTelemetry tracing and rrweb session
|
|
50
|
+
* replay, both tagged with a shared session id.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* import { MapleBrowser } from "@maple-dev/browser"
|
|
55
|
+
*
|
|
56
|
+
* MapleBrowser.init({
|
|
57
|
+
* ingestKey: "maple_pk_...",
|
|
58
|
+
* serviceName: "acme-web",
|
|
59
|
+
* })
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
declare const MapleBrowser: {
|
|
63
|
+
init: (config: MapleBrowserConfig) => MapleBrowserHandle; /** Attach (or replace) the user id on the active session. Safe to call repeatedly. */
|
|
64
|
+
identify: (userId: string) => void;
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
export { MapleBrowser, type MapleBrowserConfig, type MapleBrowserHandle };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
|
|
2
|
+
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
3
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
4
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
5
|
+
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
|
6
|
+
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
|
|
7
|
+
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
8
|
+
import { record } from "rrweb";
|
|
9
|
+
import { trace } from "@opentelemetry/api";
|
|
10
|
+
//#region src/config.ts
|
|
11
|
+
const DEFAULT_ENDPOINT = "https://ingest.maple.dev";
|
|
12
|
+
function resolveConfig(config) {
|
|
13
|
+
return {
|
|
14
|
+
ingestKey: config.ingestKey,
|
|
15
|
+
serviceName: config.serviceName,
|
|
16
|
+
endpoint: (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
|
|
17
|
+
serviceVersion: config.serviceVersion,
|
|
18
|
+
environment: config.environment,
|
|
19
|
+
userId: config.userId,
|
|
20
|
+
tracingEnabled: config.tracing?.enabled ?? true,
|
|
21
|
+
tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true,
|
|
22
|
+
replayEnabled: config.replay?.enabled ?? true,
|
|
23
|
+
replaySampleRate: config.replay?.sampleRate ?? 1,
|
|
24
|
+
maskAllInputs: config.privacy?.maskAllInputs ?? true,
|
|
25
|
+
maskAllText: config.privacy?.maskAllText ?? false
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
29
|
+
function formatCHDateTime(date) {
|
|
30
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
31
|
+
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/session.ts
|
|
35
|
+
const STORAGE_KEY = "maple.session";
|
|
36
|
+
/** Rotate the session after this much inactivity (PostHog's default). */
|
|
37
|
+
const IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
38
|
+
/** Hard cap on a single session's lifetime regardless of activity. */
|
|
39
|
+
const MAX_SESSION_MS = 1440 * 6e4;
|
|
40
|
+
/** In-memory fallback when sessionStorage is unavailable (private mode). */
|
|
41
|
+
let ephemeral;
|
|
42
|
+
function freshRecord(now) {
|
|
43
|
+
return {
|
|
44
|
+
id: crypto.randomUUID(),
|
|
45
|
+
startedAt: now,
|
|
46
|
+
lastActivityAt: now,
|
|
47
|
+
chunkSeq: 0
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function readRecord() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
53
|
+
if (!raw) return void 0;
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
|
|
56
|
+
return;
|
|
57
|
+
} catch {
|
|
58
|
+
return ephemeral;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writeRecord(record) {
|
|
62
|
+
ephemeral = record;
|
|
63
|
+
try {
|
|
64
|
+
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
function isExpired(record, now) {
|
|
68
|
+
return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the active session, rotating to a fresh one if the previous session
|
|
72
|
+
* has gone idle (or hit the lifetime cap). Touches `lastActivityAt` so calling
|
|
73
|
+
* it on page load keeps a live session alive. The id is the correlation key
|
|
74
|
+
* shared by OTel traces and replay events.
|
|
75
|
+
*/
|
|
76
|
+
function getSession() {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const existing = readRecord();
|
|
79
|
+
const record = existing && !isExpired(existing, now) ? {
|
|
80
|
+
...existing,
|
|
81
|
+
lastActivityAt: now
|
|
82
|
+
} : freshRecord(now);
|
|
83
|
+
writeRecord(record);
|
|
84
|
+
return record;
|
|
85
|
+
}
|
|
86
|
+
/** Mark the session as active right now (called as replay chunks flush). */
|
|
87
|
+
function markActivity() {
|
|
88
|
+
const record = readRecord();
|
|
89
|
+
if (!record) return;
|
|
90
|
+
writeRecord({
|
|
91
|
+
...record,
|
|
92
|
+
lastActivityAt: Date.now()
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Take the next replay chunk sequence number for the current session. Monotonic
|
|
97
|
+
* across reloads (persisted on the session record), so a refresh continues the
|
|
98
|
+
* sequence instead of restarting at 0 and overwriting the previous load's blobs.
|
|
99
|
+
*/
|
|
100
|
+
function nextChunkSeq() {
|
|
101
|
+
const record = readRecord() ?? freshRecord(Date.now());
|
|
102
|
+
const seq = record.chunkSeq;
|
|
103
|
+
writeRecord({
|
|
104
|
+
...record,
|
|
105
|
+
chunkSeq: seq + 1
|
|
106
|
+
});
|
|
107
|
+
return seq;
|
|
108
|
+
}
|
|
109
|
+
/** Best-effort UA parse — enough to populate filterable session facets. */
|
|
110
|
+
function parseUserAgent(ua) {
|
|
111
|
+
return {
|
|
112
|
+
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",
|
|
113
|
+
osName: /windows/i.test(ua) ? "Windows" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /iphone|ipad|ios/i.test(ua) ? "iOS" : /linux/i.test(ua) ? "Linux" : "Unknown",
|
|
114
|
+
deviceType: /mobile|iphone|android.*mobile/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/session-sink.ts
|
|
119
|
+
const observedTraceIds = /* @__PURE__ */ new Set();
|
|
120
|
+
/** Record a trace id seen during the session. Idempotent per id. */
|
|
121
|
+
function recordTraceId(traceId) {
|
|
122
|
+
observedTraceIds.add(traceId);
|
|
123
|
+
}
|
|
124
|
+
function getObservedTraceIds() {
|
|
125
|
+
return Array.from(observedTraceIds);
|
|
126
|
+
}
|
|
127
|
+
const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
|
|
128
|
+
/**
|
|
129
|
+
* Publish the session sink on `globalThis` so other tracers in the page (e.g. the
|
|
130
|
+
* Effect client SDK) can attach their trace ids to this replay session without a
|
|
131
|
+
* direct dependency on `@maple-dev/browser`. Reads are lazy/per-span on the consumer
|
|
132
|
+
* side, so init ordering between the SDKs does not matter.
|
|
133
|
+
*/
|
|
134
|
+
function publishSessionSink(sessionId) {
|
|
135
|
+
globalThis[SESSION_SINK_KEY] = {
|
|
136
|
+
sessionId,
|
|
137
|
+
recordTraceId
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/tracing.ts
|
|
142
|
+
/**
|
|
143
|
+
* Captures every span's trace id into the session sink. Lightweight — runs
|
|
144
|
+
* alongside the BatchSpanProcessor, does no export of its own.
|
|
145
|
+
*/
|
|
146
|
+
var TraceIdCollector = class {
|
|
147
|
+
onStart(span) {
|
|
148
|
+
recordTraceId(span.spanContext().traceId);
|
|
149
|
+
}
|
|
150
|
+
onEnd(_span) {}
|
|
151
|
+
forceFlush() {
|
|
152
|
+
return Promise.resolve();
|
|
153
|
+
}
|
|
154
|
+
shutdown() {
|
|
155
|
+
return Promise.resolve();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Set up browser OTel tracing exporting to Maple's ingest, tagging the resource
|
|
160
|
+
* with the shared `session.id`. When `tracingInstrumentFetch` is true, fetch()
|
|
161
|
+
* calls are auto-instrumented and their trace ids feed the session. Disable it
|
|
162
|
+
* when an external tracer (e.g. the Effect client SDK) already instruments
|
|
163
|
+
* requests — that tracer feeds the session via the published sink instead, and
|
|
164
|
+
* this avoids redundant duplicate network spans. Returns a shutdown function.
|
|
165
|
+
*/
|
|
166
|
+
function setupTracing(config, sessionId) {
|
|
167
|
+
const attributes = {
|
|
168
|
+
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
169
|
+
"maple.sdk.type": "browser",
|
|
170
|
+
"session.id": sessionId
|
|
171
|
+
};
|
|
172
|
+
if (config.serviceVersion) {
|
|
173
|
+
attributes[ATTR_SERVICE_VERSION] = config.serviceVersion;
|
|
174
|
+
attributes["deployment.commit_sha"] = config.serviceVersion;
|
|
175
|
+
}
|
|
176
|
+
if (config.environment) {
|
|
177
|
+
attributes["deployment.environment"] = config.environment;
|
|
178
|
+
attributes["deployment.environment.name"] = config.environment;
|
|
179
|
+
}
|
|
180
|
+
const exporter = new OTLPTraceExporter({
|
|
181
|
+
url: `${config.endpoint}/v1/traces`,
|
|
182
|
+
headers: { Authorization: `Bearer ${config.ingestKey}` }
|
|
183
|
+
});
|
|
184
|
+
const provider = new WebTracerProvider({
|
|
185
|
+
resource: resourceFromAttributes(attributes),
|
|
186
|
+
spanProcessors: [new TraceIdCollector(), new BatchSpanProcessor(exporter)]
|
|
187
|
+
});
|
|
188
|
+
provider.register();
|
|
189
|
+
if (config.tracingInstrumentFetch) registerInstrumentations({ instrumentations: [new FetchInstrumentation({ ignoreUrls: [new RegExp(`${escapeRegExp(config.endpoint)}/v1/`)] })] });
|
|
190
|
+
return () => provider.shutdown();
|
|
191
|
+
}
|
|
192
|
+
function escapeRegExp(value) {
|
|
193
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/replay/transport.ts
|
|
197
|
+
let lastWarnAt = 0;
|
|
198
|
+
function warnDropped(what, error) {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
if (now - lastWarnAt < 3e4) return;
|
|
201
|
+
lastWarnAt = now;
|
|
202
|
+
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
203
|
+
}
|
|
204
|
+
/** gzip a byte buffer using the native CompressionStream (no library). */
|
|
205
|
+
async function gzip(bytes) {
|
|
206
|
+
const stream = new CompressionStream("gzip");
|
|
207
|
+
const writer = stream.writable.getWriter();
|
|
208
|
+
writer.write(bytes);
|
|
209
|
+
writer.close();
|
|
210
|
+
const buffer = await new Response(stream.readable).arrayBuffer();
|
|
211
|
+
return new Uint8Array(buffer);
|
|
212
|
+
}
|
|
213
|
+
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
214
|
+
async function postSessionMeta(config, row, keepalive = false) {
|
|
215
|
+
const body = `${JSON.stringify(row)}\n`;
|
|
216
|
+
await fetch(`${config.endpoint}/v1/sessionReplays/meta`, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: {
|
|
219
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
220
|
+
"content-type": "application/x-ndjson"
|
|
221
|
+
},
|
|
222
|
+
body,
|
|
223
|
+
keepalive
|
|
224
|
+
}).catch((error) => {
|
|
225
|
+
warnDropped("metadata POST", error);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** POST distilled session events (NDJSON, one row per event). Best-effort. */
|
|
229
|
+
async function postSessionEvents(config, rows, keepalive = false) {
|
|
230
|
+
if (rows.length === 0) return;
|
|
231
|
+
const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
232
|
+
await fetch(`${config.endpoint}/v1/sessionEvents`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
236
|
+
"content-type": "application/x-ndjson"
|
|
237
|
+
},
|
|
238
|
+
body,
|
|
239
|
+
keepalive
|
|
240
|
+
}).catch((error) => {
|
|
241
|
+
warnDropped("events POST", error);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/** PUT a gzipped rrweb event chunk. */
|
|
245
|
+
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
246
|
+
await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
250
|
+
"content-type": "application/octet-stream",
|
|
251
|
+
"x-maple-session-id": meta.sessionId,
|
|
252
|
+
"x-maple-chunk-seq": String(meta.chunkSeq),
|
|
253
|
+
"x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
|
|
254
|
+
"x-maple-event-count": String(meta.eventCount),
|
|
255
|
+
"x-maple-duration-ms": String(meta.durationMs)
|
|
256
|
+
},
|
|
257
|
+
body: gzipped,
|
|
258
|
+
keepalive
|
|
259
|
+
}).catch((error) => {
|
|
260
|
+
warnDropped("blob PUT", error);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/replay/util.ts
|
|
265
|
+
/** Approximate byte size of an event for flush-threshold accounting. Falls back
|
|
266
|
+
* to a fixed estimate for values that can't be serialized (e.g. cycles). */
|
|
267
|
+
function approximateSize(value) {
|
|
268
|
+
try {
|
|
269
|
+
return JSON.stringify(value).length;
|
|
270
|
+
} catch {
|
|
271
|
+
return 256;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/replay/record.ts
|
|
276
|
+
const FULL_SNAPSHOT = 2;
|
|
277
|
+
const INCREMENTAL = 3;
|
|
278
|
+
const SOURCE_MOUSE_INTERACTION = 2;
|
|
279
|
+
const MOUSE_CLICK = 2;
|
|
280
|
+
const FLUSH_INTERVAL_MS$1 = 5e3;
|
|
281
|
+
const FLUSH_BYTES$1 = 100 * 1024;
|
|
282
|
+
const CHECKOUT_EVERY_MS = 3e4;
|
|
283
|
+
function startRecording(config, sessionId) {
|
|
284
|
+
let buffer = [];
|
|
285
|
+
let bufferBytes = 0;
|
|
286
|
+
let bufferHasCheckpoint = false;
|
|
287
|
+
let clickCount = 0;
|
|
288
|
+
const flush = async (keepalive = false) => {
|
|
289
|
+
if (buffer.length === 0) return;
|
|
290
|
+
markActivity();
|
|
291
|
+
const events = buffer;
|
|
292
|
+
const isCheckpoint = bufferHasCheckpoint;
|
|
293
|
+
const seq = nextChunkSeq();
|
|
294
|
+
const first = events[0].timestamp;
|
|
295
|
+
const last = events[events.length - 1].timestamp;
|
|
296
|
+
buffer = [];
|
|
297
|
+
bufferBytes = 0;
|
|
298
|
+
bufferHasCheckpoint = false;
|
|
299
|
+
const gzipped = await gzip(new TextEncoder().encode(JSON.stringify(events)));
|
|
300
|
+
await postSessionBlob(config, {
|
|
301
|
+
sessionId,
|
|
302
|
+
chunkSeq: seq,
|
|
303
|
+
isCheckpoint,
|
|
304
|
+
eventCount: events.length,
|
|
305
|
+
durationMs: Math.max(0, last - first)
|
|
306
|
+
}, gzipped, keepalive);
|
|
307
|
+
};
|
|
308
|
+
const stop = record({
|
|
309
|
+
emit: (event, isCheckpoint) => {
|
|
310
|
+
const e = event;
|
|
311
|
+
if (isCheckpoint === true || e.type === FULL_SNAPSHOT) bufferHasCheckpoint = true;
|
|
312
|
+
if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
|
|
313
|
+
buffer.push(e);
|
|
314
|
+
bufferBytes += approximateSize(e);
|
|
315
|
+
if (bufferBytes >= FLUSH_BYTES$1) flush();
|
|
316
|
+
},
|
|
317
|
+
maskAllInputs: config.maskAllInputs,
|
|
318
|
+
...config.maskAllText ? { maskTextSelector: "*" } : {},
|
|
319
|
+
checkoutEveryNms: CHECKOUT_EVERY_MS
|
|
320
|
+
});
|
|
321
|
+
const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS$1);
|
|
322
|
+
return {
|
|
323
|
+
stop: () => {
|
|
324
|
+
clearInterval(flushTimer);
|
|
325
|
+
stop?.();
|
|
326
|
+
},
|
|
327
|
+
flush,
|
|
328
|
+
getClickCount: () => clickCount
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/replay/capture/shared.ts
|
|
333
|
+
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
334
|
+
function safeEmit(emit, ev) {
|
|
335
|
+
try {
|
|
336
|
+
emit(ev);
|
|
337
|
+
} catch {}
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/replay/capture/console.ts
|
|
341
|
+
const LEVELS = [
|
|
342
|
+
"log",
|
|
343
|
+
"info",
|
|
344
|
+
"warn",
|
|
345
|
+
"error",
|
|
346
|
+
"debug"
|
|
347
|
+
];
|
|
348
|
+
const MAX_MESSAGE = 2e3;
|
|
349
|
+
/**
|
|
350
|
+
* Capture `console.*` calls as session events. Wraps each method, emits a
|
|
351
|
+
* distilled record, then forwards to the original so the host app's console
|
|
352
|
+
* behaves normally. Never throws into the call site.
|
|
353
|
+
*/
|
|
354
|
+
function installConsoleCapture(emit) {
|
|
355
|
+
const original = {};
|
|
356
|
+
for (const level of LEVELS) {
|
|
357
|
+
const orig = console[level];
|
|
358
|
+
original[level] = orig;
|
|
359
|
+
console[level] = (...args) => {
|
|
360
|
+
safeEmit(emit, {
|
|
361
|
+
type: "console",
|
|
362
|
+
level,
|
|
363
|
+
message: formatArgs(args)
|
|
364
|
+
});
|
|
365
|
+
orig.apply(console, args);
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return () => {
|
|
369
|
+
for (const level of LEVELS) {
|
|
370
|
+
const orig = original[level];
|
|
371
|
+
if (orig) console[level] = orig;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function formatArgs(args) {
|
|
376
|
+
const text = args.map((a) => {
|
|
377
|
+
if (typeof a === "string") return a;
|
|
378
|
+
if (a instanceof Error) return `${a.name}: ${a.message}`;
|
|
379
|
+
try {
|
|
380
|
+
return JSON.stringify(a);
|
|
381
|
+
} catch {
|
|
382
|
+
return String(a);
|
|
383
|
+
}
|
|
384
|
+
}).join(" ");
|
|
385
|
+
return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/replay/capture/network.ts
|
|
389
|
+
/**
|
|
390
|
+
* Capture fetch + XHR requests as session events, tagged with the active trace
|
|
391
|
+
* id so each request links to its backend trace. `ignoreUrl` skips Maple's own
|
|
392
|
+
* ingest endpoints (otherwise capturing the session-events POST would loop).
|
|
393
|
+
*/
|
|
394
|
+
function installNetworkCapture(emit, ignoreUrl) {
|
|
395
|
+
const origFetch = typeof window !== "undefined" ? window.fetch : void 0;
|
|
396
|
+
if (origFetch) window.fetch = async (input, init) => {
|
|
397
|
+
const url = requestUrl(input);
|
|
398
|
+
const method = requestMethod(input, init);
|
|
399
|
+
const traceId = activeTraceId();
|
|
400
|
+
const start = performance.now();
|
|
401
|
+
try {
|
|
402
|
+
const res = await origFetch(input, init);
|
|
403
|
+
record(url, method, res.status, start, traceId);
|
|
404
|
+
return res;
|
|
405
|
+
} catch (error) {
|
|
406
|
+
record(url, method, 0, start, traceId, String(error));
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const record = (url, method, status, start, traceId, error) => {
|
|
411
|
+
if (ignoreUrl(url)) return;
|
|
412
|
+
safeEmit(emit, {
|
|
413
|
+
type: "network",
|
|
414
|
+
net: {
|
|
415
|
+
method,
|
|
416
|
+
url,
|
|
417
|
+
status,
|
|
418
|
+
durationMs: Math.round(performance.now() - start)
|
|
419
|
+
},
|
|
420
|
+
traceId,
|
|
421
|
+
...error ? { attrs: { error } } : {}
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
|
|
425
|
+
const origOpen = XHR?.prototype.open;
|
|
426
|
+
const origSend = XHR?.prototype.send;
|
|
427
|
+
if (XHR && origOpen && origSend) {
|
|
428
|
+
XHR.prototype.open = function(method, url, ...rest) {
|
|
429
|
+
this.__mapleMethod = String(method).toUpperCase();
|
|
430
|
+
this.__mapleUrl = typeof url === "string" ? url : url.href;
|
|
431
|
+
return origOpen.apply(this, [
|
|
432
|
+
method,
|
|
433
|
+
url,
|
|
434
|
+
...rest
|
|
435
|
+
]);
|
|
436
|
+
};
|
|
437
|
+
XHR.prototype.send = function(...args) {
|
|
438
|
+
const meta = this;
|
|
439
|
+
const start = performance.now();
|
|
440
|
+
const traceId = activeTraceId();
|
|
441
|
+
this.addEventListener("loadend", () => {
|
|
442
|
+
record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
|
|
443
|
+
});
|
|
444
|
+
return origSend.apply(this, args);
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return () => {
|
|
448
|
+
if (origFetch) window.fetch = origFetch;
|
|
449
|
+
if (XHR && origOpen) XHR.prototype.open = origOpen;
|
|
450
|
+
if (XHR && origSend) XHR.prototype.send = origSend;
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function requestUrl(input) {
|
|
454
|
+
if (typeof input === "string") return input;
|
|
455
|
+
if (input instanceof URL) return input.href;
|
|
456
|
+
return input.url;
|
|
457
|
+
}
|
|
458
|
+
function requestMethod(input, init) {
|
|
459
|
+
return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/replay/capture/errors.ts
|
|
463
|
+
const MAX_STACK = 4e3;
|
|
464
|
+
/** Capture uncaught errors + unhandled promise rejections as session events. */
|
|
465
|
+
function installErrorCapture(emit) {
|
|
466
|
+
const onError = (event) => {
|
|
467
|
+
safeEmit(emit, {
|
|
468
|
+
type: "error",
|
|
469
|
+
level: "error",
|
|
470
|
+
message: event.message || String(event.error ?? "Error"),
|
|
471
|
+
errorStack: truncate(event.error?.stack),
|
|
472
|
+
traceId: activeTraceId()
|
|
473
|
+
});
|
|
474
|
+
};
|
|
475
|
+
const onRejection = (event) => {
|
|
476
|
+
const reason = event.reason;
|
|
477
|
+
safeEmit(emit, {
|
|
478
|
+
type: "error",
|
|
479
|
+
level: "error",
|
|
480
|
+
message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
|
|
481
|
+
errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
|
|
482
|
+
traceId: activeTraceId()
|
|
483
|
+
});
|
|
484
|
+
};
|
|
485
|
+
window.addEventListener("error", onError);
|
|
486
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
487
|
+
return () => {
|
|
488
|
+
window.removeEventListener("error", onError);
|
|
489
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function truncate(stack) {
|
|
493
|
+
if (!stack) return void 0;
|
|
494
|
+
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/replay/capture/navigation.ts
|
|
498
|
+
/**
|
|
499
|
+
* Capture page views as session events: the initial load plus every SPA
|
|
500
|
+
* navigation (history pushState/replaceState, popstate, hashchange).
|
|
501
|
+
*/
|
|
502
|
+
function installNavigationCapture(emit) {
|
|
503
|
+
let lastUrl = "";
|
|
504
|
+
const emitNav = () => {
|
|
505
|
+
const url = location.href;
|
|
506
|
+
if (url === lastUrl) return;
|
|
507
|
+
lastUrl = url;
|
|
508
|
+
safeEmit(emit, {
|
|
509
|
+
type: "navigation",
|
|
510
|
+
url
|
|
511
|
+
});
|
|
512
|
+
};
|
|
513
|
+
emitNav();
|
|
514
|
+
const origPush = history.pushState;
|
|
515
|
+
const origReplace = history.replaceState;
|
|
516
|
+
history.pushState = function(...args) {
|
|
517
|
+
const result = origPush.apply(this, args);
|
|
518
|
+
emitNav();
|
|
519
|
+
return result;
|
|
520
|
+
};
|
|
521
|
+
history.replaceState = function(...args) {
|
|
522
|
+
const result = origReplace.apply(this, args);
|
|
523
|
+
emitNav();
|
|
524
|
+
return result;
|
|
525
|
+
};
|
|
526
|
+
window.addEventListener("popstate", emitNav);
|
|
527
|
+
window.addEventListener("hashchange", emitNav);
|
|
528
|
+
return () => {
|
|
529
|
+
history.pushState = origPush;
|
|
530
|
+
history.replaceState = origReplace;
|
|
531
|
+
window.removeEventListener("popstate", emitNav);
|
|
532
|
+
window.removeEventListener("hashchange", emitNav);
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/replay/capture/interactions.ts
|
|
537
|
+
const MAX_TEXT = 120;
|
|
538
|
+
/**
|
|
539
|
+
* Capture clicks and input events as session events. Listens in the capture
|
|
540
|
+
* phase so it sees interactions even when the host app calls
|
|
541
|
+
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
542
|
+
* element. Click target text is omitted when `maskAllText` is set.
|
|
543
|
+
*/
|
|
544
|
+
function installInteractionCapture(emit, maskAllText) {
|
|
545
|
+
const onClick = (event) => {
|
|
546
|
+
const target = event.target;
|
|
547
|
+
if (!(target instanceof Element)) return;
|
|
548
|
+
safeEmit(emit, {
|
|
549
|
+
type: "click",
|
|
550
|
+
targetSelector: selectorOf(target),
|
|
551
|
+
targetText: maskAllText ? void 0 : textOf(target)
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
const onInput = (event) => {
|
|
555
|
+
const target = event.target;
|
|
556
|
+
if (!(target instanceof Element)) return;
|
|
557
|
+
safeEmit(emit, {
|
|
558
|
+
type: "input",
|
|
559
|
+
targetSelector: selectorOf(target)
|
|
560
|
+
});
|
|
561
|
+
};
|
|
562
|
+
document.addEventListener("click", onClick, true);
|
|
563
|
+
document.addEventListener("input", onInput, true);
|
|
564
|
+
return () => {
|
|
565
|
+
document.removeEventListener("click", onClick, true);
|
|
566
|
+
document.removeEventListener("input", onInput, true);
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/** A short, human-readable selector: tag + #id + .first-class. */
|
|
570
|
+
function selectorOf(el) {
|
|
571
|
+
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
|
|
572
|
+
}
|
|
573
|
+
function textOf(el) {
|
|
574
|
+
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
575
|
+
if (!text) return void 0;
|
|
576
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
|
|
577
|
+
}
|
|
578
|
+
//#endregion
|
|
579
|
+
//#region src/replay/events.ts
|
|
580
|
+
const FLUSH_INTERVAL_MS = 5e3;
|
|
581
|
+
const FLUSH_BYTES = 64 * 1024;
|
|
582
|
+
const ZERO_TRACE_ID = "00000000000000000000000000000000";
|
|
583
|
+
/** The OTel trace id of the active span, or undefined when none is active. */
|
|
584
|
+
function activeTraceId() {
|
|
585
|
+
const id = trace.getActiveSpan()?.spanContext().traceId;
|
|
586
|
+
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Capture distilled session events (console, network, errors, navigation,
|
|
590
|
+
* interactions) and ship them to the ingest gateway as NDJSON rows. Best-effort
|
|
591
|
+
* and decoupled from the rrweb recorder — runs on its own flush loop.
|
|
592
|
+
*/
|
|
593
|
+
function startEventCapture(config, sessionId) {
|
|
594
|
+
let buffer = [];
|
|
595
|
+
let bufferBytes = 0;
|
|
596
|
+
let seq = 0;
|
|
597
|
+
const emit = (ev) => {
|
|
598
|
+
buffer.push({
|
|
599
|
+
ev,
|
|
600
|
+
seq: seq++
|
|
601
|
+
});
|
|
602
|
+
bufferBytes += approximateSize(ev);
|
|
603
|
+
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
604
|
+
};
|
|
605
|
+
const flush = async (keepalive = false) => {
|
|
606
|
+
if (buffer.length === 0) return;
|
|
607
|
+
markActivity();
|
|
608
|
+
const batch = buffer;
|
|
609
|
+
buffer = [];
|
|
610
|
+
bufferBytes = 0;
|
|
611
|
+
await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
|
|
612
|
+
};
|
|
613
|
+
const ignoreUrl = (url) => url.startsWith(`${config.endpoint}/v1/`);
|
|
614
|
+
const uninstall = [
|
|
615
|
+
installNavigationCapture(emit),
|
|
616
|
+
installInteractionCapture(emit, config.maskAllText),
|
|
617
|
+
installConsoleCapture(emit),
|
|
618
|
+
installNetworkCapture(emit, ignoreUrl),
|
|
619
|
+
installErrorCapture(emit)
|
|
620
|
+
];
|
|
621
|
+
const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
|
|
622
|
+
return {
|
|
623
|
+
stop: () => {
|
|
624
|
+
clearInterval(flushTimer);
|
|
625
|
+
for (const off of uninstall) off();
|
|
626
|
+
},
|
|
627
|
+
flush
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/** Map an internal event to the snake_case ingest row (org_id is added server-side). */
|
|
631
|
+
function toRow(sessionId, ev, seq) {
|
|
632
|
+
return {
|
|
633
|
+
session_id: sessionId,
|
|
634
|
+
timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
|
|
635
|
+
seq,
|
|
636
|
+
type: ev.type,
|
|
637
|
+
url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
|
|
638
|
+
trace_id: ev.traceId ?? activeTraceId() ?? "",
|
|
639
|
+
level: ev.level ?? "",
|
|
640
|
+
message: ev.message ?? "",
|
|
641
|
+
target_selector: ev.targetSelector ?? "",
|
|
642
|
+
target_text: ev.targetText ?? "",
|
|
643
|
+
net_method: ev.net?.method ?? "",
|
|
644
|
+
net_url: ev.net?.url ?? "",
|
|
645
|
+
net_status: ev.net?.status ?? 0,
|
|
646
|
+
net_duration_ms: ev.net?.durationMs ?? 0,
|
|
647
|
+
error_stack: ev.errorStack ?? "",
|
|
648
|
+
attributes: ev.attrs ?? {}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region src/init.ts
|
|
653
|
+
let active;
|
|
654
|
+
let activeConfig;
|
|
655
|
+
/**
|
|
656
|
+
* Initialize Maple browser telemetry: OTel tracing + (sampled) rrweb session
|
|
657
|
+
* replay, both tagged with one shared session id so a trace can link to its
|
|
658
|
+
* replay and vice versa. Idempotent — repeated calls return the live handle.
|
|
659
|
+
*/
|
|
660
|
+
function init(rawConfig) {
|
|
661
|
+
if (active) return active;
|
|
662
|
+
if (typeof window === "undefined") return {
|
|
663
|
+
sessionId: "",
|
|
664
|
+
shutdown: () => Promise.resolve()
|
|
665
|
+
};
|
|
666
|
+
const config = resolveConfig(rawConfig);
|
|
667
|
+
activeConfig = config;
|
|
668
|
+
const session = getSession();
|
|
669
|
+
const sessionId = session.id;
|
|
670
|
+
const startedAt = new Date(session.startedAt);
|
|
671
|
+
publishSessionSink(sessionId);
|
|
672
|
+
const shutdownTracing = config.tracingEnabled ? setupTracing(config, sessionId) : void 0;
|
|
673
|
+
const recordReplay = config.replayEnabled && Math.random() < config.replaySampleRate;
|
|
674
|
+
let recorder;
|
|
675
|
+
let events;
|
|
676
|
+
if (recordReplay) {
|
|
677
|
+
recorder = startRecording(config, sessionId);
|
|
678
|
+
events = startEventCapture(config, sessionId);
|
|
679
|
+
postSessionMeta(config, sessionMetaRow(config, sessionId, startedAt, 1, "active", null));
|
|
680
|
+
installLifecycleHandlers(config, sessionId, startedAt, recorder, events);
|
|
681
|
+
}
|
|
682
|
+
const handle = {
|
|
683
|
+
sessionId,
|
|
684
|
+
shutdown: async () => {
|
|
685
|
+
if (recorder) await recorder.flush(true);
|
|
686
|
+
if (events) await events.flush(true);
|
|
687
|
+
recorder?.stop();
|
|
688
|
+
events?.stop();
|
|
689
|
+
await shutdownTracing?.();
|
|
690
|
+
active = void 0;
|
|
691
|
+
activeConfig = void 0;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
active = handle;
|
|
695
|
+
return handle;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Attach (or replace) the user id on the active session. Idempotent and safe to
|
|
699
|
+
* call on every render. The session's authoritative row is the `Version=2`
|
|
700
|
+
* "ended" row posted on unload, which reads `config.userId` at that moment — so
|
|
701
|
+
* an id set here before the session ends is what the session is tagged with.
|
|
702
|
+
* We deliberately do not re-post the active row (the SDK already writes a fresh
|
|
703
|
+
* `Version=1` row on every reload; a second write here would collide under
|
|
704
|
+
* `argMax(field, Version)`).
|
|
705
|
+
*/
|
|
706
|
+
function identify(userId) {
|
|
707
|
+
if (typeof window === "undefined") return;
|
|
708
|
+
if (!activeConfig) return;
|
|
709
|
+
if (!userId) return;
|
|
710
|
+
if (activeConfig.userId === userId) return;
|
|
711
|
+
activeConfig.userId = userId;
|
|
712
|
+
}
|
|
713
|
+
function installLifecycleHandlers(config, sessionId, startedAt, recorder, events) {
|
|
714
|
+
let finalized = false;
|
|
715
|
+
const finalize = () => {
|
|
716
|
+
if (finalized) return;
|
|
717
|
+
finalized = true;
|
|
718
|
+
recorder.flush(true);
|
|
719
|
+
events.flush(true);
|
|
720
|
+
postSessionMeta(config, sessionMetaRow(config, sessionId, startedAt, 2, "ended", recorder.getClickCount()), true);
|
|
721
|
+
recorder.stop();
|
|
722
|
+
events.stop();
|
|
723
|
+
};
|
|
724
|
+
document.addEventListener("visibilitychange", () => {
|
|
725
|
+
if (document.visibilityState === "hidden") finalize();
|
|
726
|
+
});
|
|
727
|
+
window.addEventListener("pagehide", finalize);
|
|
728
|
+
}
|
|
729
|
+
function sessionMetaRow(config, sessionId, startedAt, version, status, clickCount) {
|
|
730
|
+
const ua = parseUserAgent(navigator.userAgent);
|
|
731
|
+
const now = /* @__PURE__ */ new Date();
|
|
732
|
+
const row = {
|
|
733
|
+
session_id: sessionId,
|
|
734
|
+
start_time: formatCHDateTime(startedAt),
|
|
735
|
+
status,
|
|
736
|
+
version,
|
|
737
|
+
user_id: config.userId ?? "",
|
|
738
|
+
url_initial: window.location.href,
|
|
739
|
+
user_agent: navigator.userAgent,
|
|
740
|
+
browser_name: ua.browserName,
|
|
741
|
+
os_name: ua.osName,
|
|
742
|
+
device_type: ua.deviceType,
|
|
743
|
+
service_name: config.serviceName,
|
|
744
|
+
resource_attributes: config.environment ? {
|
|
745
|
+
"deployment.environment": config.environment,
|
|
746
|
+
"deployment.environment.name": config.environment
|
|
747
|
+
} : {}
|
|
748
|
+
};
|
|
749
|
+
if (status === "ended") {
|
|
750
|
+
row.end_time = formatCHDateTime(now);
|
|
751
|
+
row.duration_ms = Math.max(0, now.getTime() - startedAt.getTime());
|
|
752
|
+
row.click_count = clickCount ?? 0;
|
|
753
|
+
row.trace_ids = getObservedTraceIds();
|
|
754
|
+
}
|
|
755
|
+
return row;
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/index.ts
|
|
759
|
+
/**
|
|
760
|
+
* Maple browser SDK. One call wires up OpenTelemetry tracing and rrweb session
|
|
761
|
+
* replay, both tagged with a shared session id.
|
|
762
|
+
*
|
|
763
|
+
* @example
|
|
764
|
+
* ```ts
|
|
765
|
+
* import { MapleBrowser } from "@maple-dev/browser"
|
|
766
|
+
*
|
|
767
|
+
* MapleBrowser.init({
|
|
768
|
+
* ingestKey: "maple_pk_...",
|
|
769
|
+
* serviceName: "acme-web",
|
|
770
|
+
* })
|
|
771
|
+
* ```
|
|
772
|
+
*/
|
|
773
|
+
const MapleBrowser = {
|
|
774
|
+
init,
|
|
775
|
+
identify
|
|
776
|
+
};
|
|
777
|
+
//#endregion
|
|
778
|
+
export { MapleBrowser };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maple-dev/browser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Maple browser SDK — OpenTelemetry tracing and rrweb session replay in one package. Every span and replay event shares a session id for trace↔replay correlation.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"import": "./dist/index.mjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsdown",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@opentelemetry/api": "^1.9.0",
|
|
24
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.205.0",
|
|
25
|
+
"@opentelemetry/instrumentation": "^0.205.0",
|
|
26
|
+
"@opentelemetry/instrumentation-fetch": "^0.205.0",
|
|
27
|
+
"@opentelemetry/resources": "^2.0.0",
|
|
28
|
+
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
29
|
+
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
|
30
|
+
"@opentelemetry/semantic-conventions": "^1.36.0",
|
|
31
|
+
"rrweb": "^2.0.0-alpha.18"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"tsdown": "^0.21.7",
|
|
35
|
+
"typescript": "^6.0.2",
|
|
36
|
+
"vitest": "^4.1.2"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"maple",
|
|
40
|
+
"opentelemetry",
|
|
41
|
+
"session-replay",
|
|
42
|
+
"rrweb",
|
|
43
|
+
"observability",
|
|
44
|
+
"rum"
|
|
45
|
+
]
|
|
46
|
+
}
|