@maple-dev/browser 0.9.0 → 0.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maple-dev/browser",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
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
5
  "keywords": [
6
6
  "maple",
@@ -30,6 +30,10 @@
30
30
  ".": {
31
31
  "types": "./dist/index.d.mts",
32
32
  "import": "./dist/index.mjs"
33
+ },
34
+ "./react": {
35
+ "types": "./dist/react.d.mts",
36
+ "import": "./dist/react.mjs"
33
37
  }
34
38
  },
35
39
  "publishConfig": {
@@ -43,21 +47,39 @@
43
47
  },
44
48
  "dependencies": {
45
49
  "@opentelemetry/api": "^1.9.0",
46
- "@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
47
- "@opentelemetry/instrumentation": "^0.221.0",
48
- "@opentelemetry/instrumentation-fetch": "^0.221.0",
50
+ "@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
51
+ "@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
52
+ "@opentelemetry/instrumentation": "^0.222.0",
53
+ "@opentelemetry/instrumentation-fetch": "^0.222.0",
54
+ "@opentelemetry/instrumentation-xml-http-request": "^0.222.0",
55
+ "@opentelemetry/otlp-transformer": "^0.222.0",
49
56
  "@opentelemetry/resources": "^2.10.0",
57
+ "@opentelemetry/sdk-logs": "^0.222.0",
50
58
  "@opentelemetry/sdk-trace-base": "^2.10.0",
51
59
  "@opentelemetry/sdk-trace-web": "^2.10.0",
52
60
  "@opentelemetry/semantic-conventions": "^1.43.0",
53
- "rrweb": "^2.0.0-alpha.18"
61
+ "rrweb": "^2.0.0-alpha.18",
62
+ "web-vitals": "^6.2.2"
54
63
  },
55
64
  "devDependencies": {
56
65
  "@maple/browser-session": "0.1.0",
57
- "@vitest/browser-playwright": "^5.0.1",
58
- "playwright": "^1.62.1",
59
- "tsdown": "^0.22.14",
60
- "typescript": "^6.0.3",
61
- "vitest": "^5.0.1"
66
+ "@maple/sdk-core": "0.1.0",
67
+ "@types/react": "^19.3.0",
68
+ "@types/react-dom": "^19.3.0",
69
+ "@vitest/browser-playwright": "^5.0.2",
70
+ "playwright": "^1.63.0",
71
+ "react": "^19.3.0",
72
+ "react-dom": "^19.3.0",
73
+ "tsdown": "^0.23.0",
74
+ "typescript": "^7.0.2",
75
+ "vitest": "^5.0.2"
76
+ },
77
+ "peerDependencies": {
78
+ "react": ">=18"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "react": {
82
+ "optional": true
83
+ }
62
84
  }
63
85
  }
@@ -1,360 +0,0 @@
1
- import { C as warnDropped, E as activeTraceId, M as scrubUrl, O as withStartedTraceId, T as safeEmit, b as postSessionBlob, m as nextChunkSeq, n as getObservedTraceIds, o as startSessionLifecycle, p as markActivity, r as publishSessionSink, u as startEventSink, w as BLOCK_SELECTOR, x as postSessionMeta, y as gzip } from "./sink-DHPiMgU0.mjs";
2
- import { record } from "rrweb";
3
- //#region ../browser-session/src/replay/capture/console.ts
4
- const LEVELS = [
5
- "log",
6
- "info",
7
- "warn",
8
- "error",
9
- "debug"
10
- ];
11
- const MAX_MESSAGE = 2e3;
12
- /** No array logged into a 2,000-character message needs more elements than this. */
13
- const MAX_ARRAY_ELEMENTS = 100;
14
- /**
15
- * Capture `console.*` calls as session events. Wraps each method, emits a
16
- * distilled record, then forwards to the original so the host app's console
17
- * behaves normally. Never throws into the call site.
18
- */
19
- function installConsoleCapture(emit) {
20
- const original = {};
21
- for (const level of LEVELS) {
22
- const orig = console[level];
23
- original[level] = orig;
24
- console[level] = (...args) => {
25
- safeEmit(emit, {
26
- type: "console",
27
- level,
28
- message: formatArgs(args)
29
- });
30
- orig.apply(console, args);
31
- };
32
- }
33
- return () => {
34
- for (const level of LEVELS) {
35
- const orig = original[level];
36
- if (orig) console[level] = orig;
37
- }
38
- };
39
- }
40
- function formatArgs(args) {
41
- const text = args.map((a) => {
42
- if (typeof a === "string") return a;
43
- if (a instanceof Error) return `${a.name}: ${a.message}`;
44
- try {
45
- return boundedStringify(a, MAX_MESSAGE) ?? String(a);
46
- } catch {
47
- return String(a);
48
- }
49
- }).join(" ");
50
- return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
51
- }
52
- /**
53
- * `JSON.stringify` that stops descending once roughly `budget` characters have
54
- * been produced. The message is cut to `MAX_MESSAGE` anyway, and serializing a
55
- * whole store object on every `console.log` first was a main-thread cost paid
56
- * on the host app's hot path. A replacer returning `undefined` skips a value
57
- * without visiting its children, so the walk is bounded by the budget.
58
- */
59
- function boundedStringify(value, budget) {
60
- let used = 0;
61
- return JSON.stringify(value, (key, nested) => {
62
- if (used > budget) return void 0;
63
- used += key.length + (typeof nested === "string" ? nested.length : 4);
64
- if (Array.isArray(nested) && nested.length > MAX_ARRAY_ELEMENTS) return nested.slice(0, MAX_ARRAY_ELEMENTS);
65
- return nested;
66
- });
67
- }
68
- //#endregion
69
- //#region ../browser-session/src/replay/capture/network.ts
70
- /**
71
- * Capture fetch + XHR requests as session events, tagged with the active trace
72
- * id so each request links to its backend trace. `ignoreUrl` skips Maple's own
73
- * ingest endpoints (otherwise capturing the session-events POST would loop).
74
- */
75
- function installNetworkCapture(emit, ignoreUrl) {
76
- const origFetch = typeof window !== "undefined" ? window.fetch : void 0;
77
- if (origFetch) window.fetch = async (input, init) => {
78
- const url = requestUrl(input);
79
- const method = requestMethod(input, init);
80
- const ambientTraceId = activeTraceId();
81
- const start = performance.now();
82
- let traceId = ambientTraceId;
83
- try {
84
- const call = withStartedTraceId(() => origFetch(input, init));
85
- traceId = call.traceId ?? ambientTraceId;
86
- const res = await call.result;
87
- record(url, method, res.status, start, traceId);
88
- return res;
89
- } catch (error) {
90
- record(url, method, 0, start, traceId, String(error));
91
- throw error;
92
- }
93
- };
94
- const record = (url, method, status, start, traceId, error) => {
95
- if (ignoreUrl(url)) return;
96
- safeEmit(emit, {
97
- type: "network",
98
- net: {
99
- method,
100
- url,
101
- status,
102
- durationMs: Math.round(performance.now() - start)
103
- },
104
- traceId,
105
- ...error ? { attrs: { error } } : void 0
106
- });
107
- };
108
- const XHR = typeof window !== "undefined" ? window.XMLHttpRequest : void 0;
109
- const origOpen = XHR?.prototype.open;
110
- const origSend = XHR?.prototype.send;
111
- if (XHR && origOpen && origSend) {
112
- XHR.prototype.open = function(method, url, ...rest) {
113
- this.__mapleMethod = String(method).toUpperCase();
114
- this.__mapleUrl = typeof url === "string" ? url : url.href;
115
- return origOpen.apply(this, [
116
- method,
117
- url,
118
- ...rest
119
- ]);
120
- };
121
- XHR.prototype.send = function(...args) {
122
- const meta = this;
123
- const start = performance.now();
124
- let traceId = activeTraceId();
125
- this.addEventListener("loadend", () => {
126
- record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId);
127
- });
128
- const call = withStartedTraceId(() => origSend.apply(this, args));
129
- traceId = call.traceId ?? traceId;
130
- return call.result;
131
- };
132
- }
133
- return () => {
134
- if (origFetch) window.fetch = origFetch;
135
- if (XHR && origOpen) XHR.prototype.open = origOpen;
136
- if (XHR && origSend) XHR.prototype.send = origSend;
137
- };
138
- }
139
- function requestUrl(input) {
140
- if (typeof input === "string") return input;
141
- if (input instanceof URL) return input.href;
142
- return input.url;
143
- }
144
- function requestMethod(input, init) {
145
- return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : void 0) ?? "GET").toUpperCase();
146
- }
147
- //#endregion
148
- //#region ../browser-session/src/replay/events.ts
149
- /**
150
- * Install the capture modules that turn console output and network requests
151
- * into distilled session events.
152
- *
153
- * Navigation, errors and interactions are deliberately absent: the sink
154
- * installs those itself (see `startBaselineCapture`), because page views, error
155
- * counts and click counts have to be counted even when replay is unsampled —
156
- * and a second listener here would double-count every one of them.
157
- */
158
- function startEventCapture(config, sessionId) {
159
- const sink = startEventSink(config, sessionId);
160
- const emit = sink.emit;
161
- const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl)];
162
- return {
163
- stop: () => {
164
- for (const off of uninstall) off();
165
- },
166
- flush: sink.flush
167
- };
168
- }
169
- //#endregion
170
- //#region ../browser-session/src/replay/record.ts
171
- const FULL_SNAPSHOT = 2;
172
- const META = 4;
173
- const INCREMENTAL = 3;
174
- const SOURCE_MOUSE_INTERACTION = 2;
175
- const MOUSE_CLICK = 2;
176
- const FLUSH_INTERVAL_MS = 5e3;
177
- const FLUSH_BYTES = 102400;
178
- const CHECKOUT_EVERY_MS = 3e5;
179
- const MAX_BUFFER_BYTES = 4194304;
180
- function warnExhausted(sessionId) {
181
- console.warn(`[maple] session replay ${sessionId} reached its maximum recorded size; recording stopped for this session (metadata and events continue)`);
182
- }
183
- let lastDropWarnAt = 0;
184
- function warnBufferDropped(bytes) {
185
- const now = Date.now();
186
- if (now - lastDropWarnAt < 3e4) return;
187
- lastDropWarnAt = now;
188
- console.warn(`[maple] session replay buffer exceeded ${MAX_BUFFER_BYTES} bytes (dropping ${bytes} buffered bytes; recording continues from the next full snapshot)`);
189
- }
190
- function startRecording(config, sessionId) {
191
- let parts = [];
192
- let bufferBytes = 0;
193
- let bufferHasCheckpoint = false;
194
- let firstTimestamp = 0;
195
- let lastTimestamp = 0;
196
- let droppedChunk = false;
197
- let clickCount = 0;
198
- let stopped = false;
199
- let exhausted = false;
200
- const resetBuffer = () => {
201
- parts = [];
202
- bufferBytes = 0;
203
- bufferHasCheckpoint = false;
204
- firstTimestamp = 0;
205
- lastTimestamp = 0;
206
- };
207
- const flush = async (keepalive = false) => {
208
- if (stopped || exhausted || parts.length === 0) return;
209
- const body = `[${parts.join(",")}]`;
210
- const isCheckpoint = bufferHasCheckpoint;
211
- const eventCount = parts.length;
212
- const durationMs = Math.max(0, lastTimestamp - firstTimestamp);
213
- const seq = nextChunkSeq();
214
- resetBuffer();
215
- let gzipped;
216
- try {
217
- gzipped = await gzip(new TextEncoder().encode(body));
218
- } catch (error) {
219
- warnDropped("chunk compression", error);
220
- return;
221
- }
222
- if (await postSessionBlob(config, {
223
- sessionId,
224
- chunkSeq: seq,
225
- isCheckpoint,
226
- eventCount,
227
- durationMs
228
- }, gzipped, keepalive) === "exhausted" && !exhausted) {
229
- exhausted = true;
230
- warnExhausted(sessionId);
231
- haltCapture();
232
- }
233
- };
234
- let haltCapture = () => {};
235
- const stop = record({
236
- emit: (event, isCheckpoint) => {
237
- const active = markActivity();
238
- if (active && active.id !== sessionId) return;
239
- const e = event;
240
- if (e.type === META && e.data && typeof e.data.href === "string") e.data.href = scrubUrl(e.data.href);
241
- const isFullSnapshot = isCheckpoint === true || e.type === FULL_SNAPSHOT;
242
- if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data.type === MOUSE_CLICK) clickCount++;
243
- let json;
244
- try {
245
- json = JSON.stringify(e);
246
- } catch {
247
- return;
248
- }
249
- if (droppedChunk && !isFullSnapshot) return;
250
- droppedChunk = false;
251
- if (bufferBytes + json.length > MAX_BUFFER_BYTES) {
252
- warnBufferDropped(bufferBytes + json.length);
253
- resetBuffer();
254
- droppedChunk = true;
255
- return;
256
- }
257
- if (isFullSnapshot) bufferHasCheckpoint = true;
258
- if (parts.length === 0) firstTimestamp = e.timestamp;
259
- lastTimestamp = e.timestamp;
260
- parts.push(json);
261
- bufferBytes += json.length;
262
- if (bufferBytes >= FLUSH_BYTES) flush();
263
- },
264
- maskAllInputs: config.maskAllInputs,
265
- blockSelector: BLOCK_SELECTOR,
266
- ...config.maskAllText ? { maskTextSelector: "*" } : void 0,
267
- checkoutEveryNms: CHECKOUT_EVERY_MS
268
- });
269
- let idleHandle;
270
- const scheduleFlush = () => {
271
- if (typeof requestIdleCallback === "function") idleHandle = requestIdleCallback(() => {
272
- idleHandle = void 0;
273
- flush();
274
- }, { timeout: 2e3 });
275
- else flush();
276
- };
277
- const flushTimer = setInterval(scheduleFlush, FLUSH_INTERVAL_MS);
278
- let halted = false;
279
- haltCapture = () => {
280
- if (halted) return;
281
- halted = true;
282
- clearInterval(flushTimer);
283
- if (idleHandle !== void 0 && typeof cancelIdleCallback === "function") {
284
- cancelIdleCallback(idleHandle);
285
- idleHandle = void 0;
286
- }
287
- resetBuffer();
288
- stop?.();
289
- };
290
- return {
291
- stop: () => {
292
- stopped = true;
293
- haltCapture();
294
- },
295
- flush,
296
- getClickCount: () => clickCount
297
- };
298
- }
299
- //#endregion
300
- //#region ../browser-session/src/session/replay-session.ts
301
- /**
302
- * Start recording the current browser session. Publishes the session sink,
303
- * posts an `active` metadata row, and installs visibility handlers:
304
- * hidden → flush + `ended` row (with observed trace ids) + stop capture;
305
- * visible → re-resolve the session (rotating if idle-expired), republish the
306
- * sink, restart capture, post a fresh `active` row. Metadata versions are
307
- * monotonic per session, so the latest row always wins on the backend.
308
- *
309
- * Returns undefined outside a browser. Sampling is the caller's decision.
310
- */
311
- function startReplaySession(options) {
312
- if (typeof window === "undefined") return void 0;
313
- const engineConfig = {
314
- endpoint: options.endpoint.replace(/\/$/, ""),
315
- ingestKey: options.ingestKey,
316
- sdk: options.sdk,
317
- maskAllInputs: options.maskAllInputs,
318
- maskAllText: options.maskAllText,
319
- getIdentity: options.getIdentity
320
- };
321
- let recorder;
322
- let events;
323
- let publishedSessionId;
324
- const publish = (sessionId) => {
325
- if (publishedSessionId === sessionId) return;
326
- publishedSessionId = sessionId;
327
- publishSessionSink(sessionId);
328
- };
329
- return startSessionLifecycle({
330
- ...options,
331
- getTraceIds: getObservedTraceIds
332
- }, {
333
- recorded: true,
334
- post: (row, keepalive) => {
335
- postSessionMeta(engineConfig, row, keepalive);
336
- },
337
- clicksSinceStart: () => recorder?.getClickCount() ?? 0,
338
- onStart: (record) => {
339
- publish(record.id);
340
- recorder = startRecording(engineConfig, record.id);
341
- events = startEventCapture(engineConfig, record.id);
342
- },
343
- onSuspend: ({ flush, keepalive }) => {
344
- const stoppingRecorder = recorder;
345
- const stoppingEvents = events;
346
- recorder = void 0;
347
- events = void 0;
348
- const flushed = flush ? Promise.all([stoppingRecorder?.flush(keepalive), stoppingEvents?.flush(keepalive)]).then(() => {}) : void 0;
349
- stoppingRecorder?.stop();
350
- stoppingEvents?.stop();
351
- return flushed;
352
- },
353
- onSessionChange: (sessionId) => {
354
- publish(sessionId);
355
- options.onSessionChange?.(sessionId);
356
- }
357
- });
358
- }
359
- //#endregion
360
- export { startReplaySession };