@watchforge/browser 0.1.3 → 0.1.5

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/src/replay.js ADDED
@@ -0,0 +1,128 @@
1
+ import { sendReplay } from "./transport.js";
2
+
3
+ const isBrowser = typeof window !== "undefined";
4
+ const MAX_BUFFER_MS = 60 * 1000;
5
+ const MAX_EVENTS = 500;
6
+
7
+ let options = {
8
+ replaysSessionSampleRate: 0,
9
+ replaysOnErrorSampleRate: 0,
10
+ maskAllInputs: true,
11
+ blockClass: "rr-block",
12
+ ignoreClass: "rr-ignore",
13
+ maskTextClass: "rr-mask",
14
+ };
15
+ let stopRecording = null;
16
+ let events = [];
17
+ let replayId = null;
18
+ let sessionId = null;
19
+ let startedAt = null;
20
+ let sessionSampled = false;
21
+
22
+ function uuid() {
23
+ if (isBrowser && crypto?.randomUUID) return crypto.randomUUID();
24
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
25
+ const r = (Math.random() * 16) | 0;
26
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
27
+ return v.toString(16);
28
+ });
29
+ }
30
+
31
+ function shouldSample(rate) {
32
+ const n = Number(rate || 0);
33
+ if (n <= 0) return false;
34
+ if (n >= 1) return true;
35
+ return Math.random() < n;
36
+ }
37
+
38
+ function trimBuffer(now = Date.now()) {
39
+ events = events.filter((event) => now - event.timestamp <= MAX_BUFFER_MS);
40
+ if (events.length > MAX_EVENTS) {
41
+ events = events.slice(-MAX_EVENTS);
42
+ }
43
+ }
44
+
45
+ export async function initReplay(config = {}) {
46
+ if (!isBrowser) return;
47
+
48
+ options = {
49
+ ...options,
50
+ ...config,
51
+ };
52
+
53
+ sessionSampled = shouldSample(options.replaysSessionSampleRate);
54
+ const shouldRecord = sessionSampled || Number(options.replaysOnErrorSampleRate || 0) > 0;
55
+
56
+ if (!shouldRecord || stopRecording) return;
57
+
58
+ replayId = replayId || uuid();
59
+ sessionId = sessionId || uuid();
60
+ startedAt = new Date().toISOString();
61
+
62
+ try {
63
+ const { record } = await import("rrweb");
64
+ stopRecording = record({
65
+ emit(event) {
66
+ events.push(event);
67
+ trimBuffer(event.timestamp || Date.now());
68
+ },
69
+ maskAllInputs: options.maskAllInputs,
70
+ blockClass: options.blockClass,
71
+ ignoreClass: options.ignoreClass,
72
+ maskTextClass: options.maskTextClass,
73
+ maskInputOptions: {
74
+ password: true,
75
+ email: true,
76
+ tel: true,
77
+ text: Boolean(options.maskAllInputs),
78
+ },
79
+ });
80
+ } catch (error) {
81
+ if (options.debug) {
82
+ console.warn("WatchForge SDK: failed to start replay recording", error);
83
+ }
84
+ }
85
+ }
86
+
87
+ export function getReplayContext() {
88
+ if (!isBrowser || !replayId || !sessionId) return null;
89
+ return {
90
+ replay_id: replayId,
91
+ session_id: sessionId,
92
+ sampled: Boolean(stopRecording),
93
+ };
94
+ }
95
+
96
+ export function flushReplayForEvent(dsn, eventId) {
97
+ if (!isBrowser || !dsn || !replayId || !sessionId || events.length === 0) {
98
+ return null;
99
+ }
100
+
101
+ if (!sessionSampled && !shouldSample(options.replaysOnErrorSampleRate)) {
102
+ return null;
103
+ }
104
+
105
+ trimBuffer();
106
+
107
+ const payload = {
108
+ replay_id: replayId,
109
+ session_id: sessionId,
110
+ event_id: eventId,
111
+ started_at: startedAt,
112
+ finished_at: new Date().toISOString(),
113
+ url: window.location.href,
114
+ user_agent: navigator.userAgent,
115
+ events,
116
+ sdk: {
117
+ name: "@watchforge/browser",
118
+ version: "0.1.3",
119
+ },
120
+ };
121
+
122
+ sendReplay(dsn, payload);
123
+ return {
124
+ replay_id: replayId,
125
+ session_id: sessionId,
126
+ event_count: events.length,
127
+ };
128
+ }
package/src/stacktrace.js CHANGED
@@ -12,8 +12,7 @@ const isNode =
12
12
  const LIBRARY_PATH_PATTERNS = [
13
13
  "/node_modules/",
14
14
  "node_modules\\",
15
- "/webpack/",
16
- "webpack-internal://",
15
+ "/webpack/runtime/",
17
16
  ];
18
17
 
19
18
  function isLibraryPath(filePath) {
@@ -25,7 +24,7 @@ function parseStackLine(line) {
25
24
  const trimmed = line.trim();
26
25
  if (!trimmed.startsWith("at ")) return null;
27
26
 
28
- const withFn = trimmed.match(/^at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
27
+ const withFn = trimmed.match(/^at\s+(?:async\s+)?(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
29
28
  if (withFn) {
30
29
  return {
31
30
  function: withFn[1] || "<anonymous>",
@@ -35,7 +34,7 @@ function parseStackLine(line) {
35
34
  };
36
35
  }
37
36
 
38
- const withoutFn = trimmed.match(/^at\s+(.*?):(\d+):(\d+)$/);
37
+ const withoutFn = trimmed.match(/^at\s+(?:async\s+)?(.*?):(\d+):(\d+)$/);
39
38
  if (withoutFn) {
40
39
  return {
41
40
  function: "<anonymous>",
@@ -110,15 +109,45 @@ function applySourceContext(frame, sourceLines, lineno) {
110
109
  .map((l) => l.replace(/\r$/, ""));
111
110
  }
112
111
 
112
+ function normalizeSourcePath(source) {
113
+ if (!source) return "";
114
+
115
+ return String(source)
116
+ .replace(/^webpack-internal:\/\/\/?/, "")
117
+ .replace(/^webpack:\/\/(?:\([^)]*\)\/)?/, "")
118
+ .replace(/^webpack:\/\/[^/]+\//, "")
119
+ .replace(/^\([^)]*\)\//, "")
120
+ .replace(/^file:\/\//, "")
121
+ .split("?")[0]
122
+ .split("#")[0]
123
+ .replace(/\\/g, "/")
124
+ .replace(/^\/+/, "")
125
+ .replace(/^\.\//, "");
126
+ }
127
+
128
+ function sourceMatchesFrame(source, framePath) {
129
+ const normalizedSource = normalizeSourcePath(source);
130
+ const normalizedFrame = normalizeSourcePath(framePath);
131
+ if (!normalizedSource || !normalizedFrame) return false;
132
+
133
+ return (
134
+ normalizedSource === normalizedFrame ||
135
+ normalizedSource.endsWith(`/${normalizedFrame}`) ||
136
+ normalizedFrame.endsWith(`/${normalizedSource}`)
137
+ );
138
+ }
139
+
113
140
  let nodeModulesPromise = null;
141
+ const importNodeBuiltin = (specifier) =>
142
+ new Function("specifier", "return import(specifier)")(specifier);
114
143
 
115
144
  function getNodeModules() {
116
145
  if (!isNode) return Promise.resolve(null);
117
146
  if (!nodeModulesPromise) {
118
147
  nodeModulesPromise = (async () => {
119
148
  try {
120
- const { createRequire } = await import("module");
121
- const { fileURLToPath } = await import("url");
149
+ const { createRequire } = await importNodeBuiltin("module");
150
+ const { fileURLToPath } = await importNodeBuiltin("url");
122
151
  const req = createRequire(fileURLToPath(import.meta.url));
123
152
  return {
124
153
  fs: req("fs"),
@@ -209,10 +238,159 @@ async function fetchBrowserSourceLines(absPath) {
209
238
  }
210
239
  }
211
240
 
241
+ async function getSourceMapConsumer() {
242
+ try {
243
+ const mod = await import("source-map-js");
244
+ return mod.SourceMapConsumer || mod.default?.SourceMapConsumer || null;
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function getSourceMappingUrl(sourceText) {
251
+ const match = sourceText.match(/\/\/# sourceMappingURL=([^\s]+)\s*$/m);
252
+ return match?.[1] || null;
253
+ }
254
+
255
+ async function fetchText(url) {
256
+ const response = await fetch(url, { credentials: "same-origin" });
257
+ if (!response.ok) return null;
258
+ const text = await response.text();
259
+ return text.length <= MAX_SOURCE_FILE_BYTES * 10 ? text : null;
260
+ }
261
+
262
+ async function fetchBrowserSourceMap(absPath) {
263
+ if (!isBrowser || !absPath) return null;
264
+ if (!absPath.startsWith("http://") && !absPath.startsWith("https://") && !absPath.startsWith("/")) {
265
+ return null;
266
+ }
267
+
268
+ try {
269
+ const scriptUrl = new URL(absPath, window.location.href);
270
+ if (scriptUrl.origin !== window.location.origin) return null;
271
+
272
+ const scriptText = await fetchText(scriptUrl.href);
273
+ if (!scriptText) return null;
274
+
275
+ const sourceMappingUrl = getSourceMappingUrl(scriptText);
276
+ if (!sourceMappingUrl) return null;
277
+
278
+ const mapUrl = new URL(sourceMappingUrl, scriptUrl.href);
279
+ if (mapUrl.origin !== window.location.origin && !sourceMappingUrl.startsWith("data:")) {
280
+ return null;
281
+ }
282
+
283
+ if (sourceMappingUrl.startsWith("data:")) {
284
+ const encoded = sourceMappingUrl.split(",", 2)[1];
285
+ if (!encoded) return null;
286
+ const decoded = decodeURIComponent(escape(atob(encoded)));
287
+ return JSON.parse(decoded);
288
+ }
289
+
290
+ const mapText = await fetchText(mapUrl.href);
291
+ return mapText ? JSON.parse(mapText) : null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ async function enrichFrameWithSourceMap(frame) {
298
+ const sourceMap = await fetchBrowserSourceMap(frame.abs_path);
299
+ if (!sourceMap) return false;
300
+
301
+ try {
302
+ const SourceMapConsumer = await getSourceMapConsumer();
303
+ if (!SourceMapConsumer) return false;
304
+
305
+ const consumer = await new SourceMapConsumer(sourceMap);
306
+ let source = null;
307
+ let lineno = frame.lineno;
308
+ let colno = frame.colno;
309
+
310
+ if (frame.lineno && frame.colno != null) {
311
+ const original = consumer.originalPositionFor({
312
+ line: frame.lineno,
313
+ column: frame.colno,
314
+ });
315
+ if (original?.source && original?.line) {
316
+ source = original.source;
317
+ lineno = original.line;
318
+ colno = original.column ?? frame.colno;
319
+ }
320
+ }
321
+
322
+ if (!source) {
323
+ source = sourceMap.sources?.find((candidate) =>
324
+ sourceMatchesFrame(candidate, frame.abs_path)
325
+ );
326
+ }
327
+
328
+ if (!source || !lineno) {
329
+ consumer.destroy?.();
330
+ return false;
331
+ }
332
+
333
+ const content =
334
+ consumer.sourceContentFor?.(source, true) ||
335
+ sourceMap.sourcesContent?.[sourceMap.sources.indexOf(source)];
336
+
337
+ consumer.destroy?.();
338
+
339
+ if (!content) return false;
340
+
341
+ frame.abs_path = source;
342
+ frame.filename = normalizeSourcePath(source).split("/").pop() || frame.filename;
343
+ frame.lineno = lineno;
344
+ frame.colno = colno;
345
+ applySourceContext(frame, content.split("\n"), lineno);
346
+ return Boolean(frame.context_line);
347
+ } catch {
348
+ return false;
349
+ }
350
+ }
351
+
352
+ function getSourceContentFromWebpackFrame(frame) {
353
+ const globalObject = typeof globalThis !== "undefined" ? globalThis : window;
354
+ const webpackChunks =
355
+ Object.values(globalObject).find(
356
+ (value) =>
357
+ Array.isArray(value) &&
358
+ value.some((item) => Array.isArray(item) && item.length >= 2)
359
+ ) || [];
360
+
361
+ const framePath = normalizeSourcePath(frame.abs_path);
362
+ if (!framePath || !Array.isArray(webpackChunks)) return null;
363
+
364
+ for (const chunk of webpackChunks) {
365
+ const modules = Array.isArray(chunk) ? chunk[1] : null;
366
+ if (!modules || typeof modules !== "object") continue;
367
+
368
+ for (const [moduleId, factory] of Object.entries(modules)) {
369
+ if (!sourceMatchesFrame(moduleId, framePath)) continue;
370
+ const source = String(factory);
371
+ return source.includes("\n") ? source.split("\n") : null;
372
+ }
373
+ }
374
+
375
+ return null;
376
+ }
377
+
212
378
  async function enrichFrameWithBrowserSource(frame) {
213
379
  if (!frame.in_app || !frame.lineno) return;
380
+
381
+ const webpackLines = getSourceContentFromWebpackFrame(frame);
382
+ if (webpackLines) {
383
+ applySourceContext(frame, webpackLines, frame.lineno);
384
+ if (frame.context_line) return;
385
+ }
386
+
214
387
  const lines = await fetchBrowserSourceLines(frame.abs_path);
215
- if (lines) applySourceContext(frame, lines, frame.lineno);
388
+ if (lines) {
389
+ applySourceContext(frame, lines, frame.lineno);
390
+ if (frame.context_line) return;
391
+ }
392
+
393
+ await enrichFrameWithSourceMap(frame);
216
394
  }
217
395
 
218
396
  export async function enrichStacktraceAsync(stacktrace) {
@@ -0,0 +1,39 @@
1
+ export interface Span {
2
+ span_id?: string;
3
+ op?: string;
4
+ description?: string;
5
+ start_timestamp?: string;
6
+ finish_timestamp?: string | null;
7
+ duration_ms?: number;
8
+ status?: string;
9
+ data?: Record<string, unknown>;
10
+ tags?: Record<string, unknown>;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export interface Transaction {
15
+ trace_id?: string;
16
+ transaction?: string;
17
+ transaction_name?: string;
18
+ op?: string;
19
+ status?: string;
20
+ spans?: Span[];
21
+ data?: Record<string, unknown>;
22
+ tags?: Record<string, unknown>;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export function initTracing(
27
+ dsn: string,
28
+ environment?: string,
29
+ debug?: boolean
30
+ ): void;
31
+
32
+ export function startTransaction(
33
+ transaction: string,
34
+ transactionName?: string,
35
+ op?: string
36
+ ): Transaction;
37
+
38
+ export function getCurrentTransaction(): Transaction | null;
39
+ export function finishTransaction(status?: string): void;
package/src/transport.js CHANGED
@@ -2,9 +2,12 @@
2
2
  const isBrowser = typeof window !== 'undefined';
3
3
  const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
4
4
 
5
- // Lazy-load Node.js require function for ES modules
5
+ // Lazy-load Node.js require function for ES modules. Use Function-based import
6
+ // so browser bundlers do not try to resolve Node built-ins like "module".
6
7
  let nodeRequire = null;
7
8
  let nodeRequirePromise = null;
9
+ const importNodeBuiltin = (specifier) =>
10
+ new Function("specifier", "return import(specifier)")(specifier);
8
11
 
9
12
  function getNodeRequire() {
10
13
  if (!isNode) return Promise.resolve(null);
@@ -14,8 +17,8 @@ function getNodeRequire() {
14
17
  if (!nodeRequirePromise) {
15
18
  nodeRequirePromise = (async () => {
16
19
  try {
17
- const { createRequire } = await import('module');
18
- const { fileURLToPath } = await import('url');
20
+ const { createRequire } = await importNodeBuiltin('module');
21
+ const { fileURLToPath } = await importNodeBuiltin('url');
19
22
  nodeRequire = createRequire(fileURLToPath(import.meta.url));
20
23
  return nodeRequire;
21
24
  } catch (e) {
@@ -47,6 +50,7 @@ function parseDsn(dsn) {
47
50
  host,
48
51
  projectId,
49
52
  apiUrl: `${finalScheme}://${host}/api/ingestion/events/`,
53
+ replayUrl: `${finalScheme}://${host}/api/ingestion/replays/`,
50
54
  };
51
55
  }
52
56
  } catch (e) {
@@ -79,6 +83,20 @@ function getApiUrl(dsn) {
79
83
  return isBrowser ? "/api/ingestion/events/" : "http://127.0.0.1:8001/api/ingestion/events/";
80
84
  }
81
85
 
86
+ function getReplayUrl(dsn) {
87
+ const apiUrl = getApiUrl(dsn);
88
+ if (apiUrl.endsWith("/events/")) {
89
+ return apiUrl.replace(/\/events\/$/, "/replays/");
90
+ }
91
+
92
+ const parsed = parseDsn(dsn);
93
+ if (parsed) {
94
+ return parsed.replayUrl;
95
+ }
96
+
97
+ return isBrowser ? "/api/ingestion/replays/" : "http://127.0.0.1:8001/api/ingestion/replays/";
98
+ }
99
+
82
100
  export function sendEvent(dsn, payload) {
83
101
  const apiUrl = getApiUrl(dsn);
84
102
 
@@ -189,3 +207,27 @@ export function sendEvent(dsn, payload) {
189
207
  });
190
208
  }
191
209
  }
210
+
211
+ export function sendReplay(dsn, payload) {
212
+ const apiUrl = getReplayUrl(dsn);
213
+
214
+ if (isBrowser) {
215
+ fetch(apiUrl, {
216
+ method: "POST",
217
+ headers: {
218
+ Authorization: `DSN ${dsn}`,
219
+ "Content-Type": "application/json",
220
+ },
221
+ body: JSON.stringify(payload),
222
+ keepalive: false,
223
+ }).catch((error) => {
224
+ console.error("WatchForge SDK - Failed to send replay:", error);
225
+ });
226
+ return;
227
+ }
228
+
229
+ if (isNode) {
230
+ // Replays are browser-only, but keep this no-op explicit for framework code paths.
231
+ return;
232
+ }
233
+ }