@watchforge/browser 0.1.1 → 0.1.4

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/client.js CHANGED
@@ -1,4 +1,19 @@
1
1
  import { sendEvent, parseDsn } from "./transport.js";
2
+ import {
3
+ buildStacktraceFromError,
4
+ enrichStacktraceAsync,
5
+ } from "./stacktrace.js";
6
+ import {
7
+ getSdkMetadata,
8
+ getPageContext,
9
+ getPerformanceContext,
10
+ getNodeServerContext,
11
+ } from "./contexts.js";
12
+ import {
13
+ flushReplayForEvent,
14
+ getReplayContext,
15
+ initReplay,
16
+ } from "./replay.js";
2
17
 
3
18
  let DSN = null;
4
19
  let APP_ENV = "production";
@@ -14,7 +29,7 @@ const isNode =
14
29
  const MAX_BREADCRUMBS = 100;
15
30
  let breadcrumbs = [];
16
31
 
17
- function addBreadcrumb(breadcrumb) {
32
+ export function addBreadcrumb(breadcrumb) {
18
33
  const entry = {
19
34
  level: "info",
20
35
  category: "log",
@@ -145,7 +160,7 @@ function setupBrowserInstrumentation() {
145
160
  message: String(msg),
146
161
  data: { url, line, col },
147
162
  });
148
- captureException(error || msg);
163
+ void captureException(error || msg);
149
164
  };
150
165
 
151
166
  window.onunhandledrejection = function (event) {
@@ -156,7 +171,7 @@ function setupBrowserInstrumentation() {
156
171
  message: String(event.reason),
157
172
  data: {},
158
173
  });
159
- captureException(event.reason);
174
+ void captureException(event.reason);
160
175
  };
161
176
 
162
177
  // Console breadcrumbs
@@ -340,78 +355,40 @@ function setupBrowserInstrumentation() {
340
355
  }
341
356
  }
342
357
 
343
- // Parse a JavaScript Error.stack string into structured frames compatible with backend
344
- function buildStacktraceFromError(error) {
345
- if (!error || !error.stack || typeof error.stack !== "string") {
346
- return null;
347
- }
348
-
349
- const stackLines = error.stack.split("\n");
350
- const frames = [];
351
-
352
- for (let i = 1; i < stackLines.length; i++) {
353
- const line = stackLines[i].trim();
354
- // Examples:
355
- // at funcName (http://localhost:5173/src/App.tsx:10:15)
356
- // at http://localhost:5173/assets/index-abc123.js:1:1234
357
- const match =
358
- line.match(/^at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)$/) ||
359
- line.match(/^at\s+(.*?):(\d+):(\d+)$/);
360
-
361
- if (!match) continue;
362
-
363
- let functionName;
364
- let file;
365
- let lineNo;
366
- let colNo;
367
-
368
- if (match.length === 5) {
369
- functionName = match[1] || "<anonymous>";
370
- file = match[2];
371
- lineNo = parseInt(match[3], 10);
372
- colNo = parseInt(match[4], 10);
373
- } else {
374
- functionName = "<anonymous>";
375
- file = match[1];
376
- lineNo = parseInt(match[2], 10);
377
- colNo = parseInt(match[3], 10);
378
- }
379
-
380
- const filename = file.split("/").pop() || file;
381
-
382
- frames.push({
383
- filename,
384
- abs_path: file,
385
- lineno: lineNo,
386
- colno: colNo,
387
- function: functionName,
388
- module: null,
389
- context_line: null,
390
- pre_context: [],
391
- post_context: [],
392
- vars: {},
393
- in_app: true,
394
- });
395
- }
396
-
397
- if (!frames.length) {
398
- return null;
399
- }
358
+ import { initTracing } from "./tracing.js";
400
359
 
401
- // Backend expects oldest frame first, newest last; JS stacks are newest first,
402
- // so we reverse to be consistent with Python-style traces.
403
- frames.reverse();
360
+ async function buildEventContexts(context = {}) {
361
+ const runtime = getRuntimeContext();
362
+ const browser = getBrowserContext();
363
+ const os = getOsContext();
364
+ const device = getDeviceContext();
365
+ const page = getPageContext();
366
+ const performanceCtx = getPerformanceContext();
367
+ const server = await getNodeServerContext();
404
368
 
405
- return { frames };
369
+ return {
370
+ ...(context.contexts || {}),
371
+ ...(runtime ? { runtime } : {}),
372
+ ...(browser ? { browser } : {}),
373
+ ...(os ? { os } : {}),
374
+ ...(device ? { device } : {}),
375
+ ...(page ? { page } : {}),
376
+ ...(performanceCtx ? { performance: performanceCtx } : {}),
377
+ ...(server ? { server } : {}),
378
+ };
406
379
  }
407
380
 
408
- import { initTracing } from "./tracing.js";
409
-
410
381
  export function register({
411
382
  dsn,
412
383
  app_env = "production",
413
384
  release = null,
414
385
  debug = false,
386
+ replaysSessionSampleRate = 0,
387
+ replaysOnErrorSampleRate = 0,
388
+ maskAllInputs = true,
389
+ blockClass = "rr-block",
390
+ ignoreClass = "rr-ignore",
391
+ maskTextClass = "rr-mask",
415
392
  }) {
416
393
  DSN = dsn;
417
394
  APP_ENV = app_env;
@@ -443,16 +420,25 @@ export function register({
443
420
  // Browser: Set up full instrumentation (errors, breadcrumbs, HTTP, navigation)
444
421
  if (isBrowser) {
445
422
  setupBrowserInstrumentation();
423
+ initReplay({
424
+ replaysSessionSampleRate,
425
+ replaysOnErrorSampleRate,
426
+ maskAllInputs,
427
+ blockClass,
428
+ ignoreClass,
429
+ maskTextClass,
430
+ debug,
431
+ });
446
432
  }
447
433
 
448
434
  // Node.js: Set up process error handlers
449
435
  if (isNode) {
450
436
  process.on("uncaughtException", (error) => {
451
- captureException(error);
437
+ void captureException(error);
452
438
  });
453
-
439
+
454
440
  process.on("unhandledRejection", (reason) => {
455
- captureException(reason);
441
+ void captureException(reason);
456
442
  });
457
443
  }
458
444
  }
@@ -463,10 +449,13 @@ export function init(options) {
463
449
  return register(options);
464
450
  }
465
451
 
466
- export function captureException(error, context = {}) {
452
+ export async function captureException(error, context = {}) {
467
453
  if (!DSN) return;
468
454
 
469
- const stacktrace = buildStacktraceFromError(error);
455
+ let stacktrace = buildStacktraceFromError(error);
456
+ if (stacktrace) {
457
+ stacktrace = await enrichStacktraceAsync(stacktrace);
458
+ }
470
459
 
471
460
  const event = {
472
461
  event_id: generateEventId(),
@@ -476,14 +465,19 @@ export function captureException(error, context = {}) {
476
465
  stacktrace: stacktrace || error?.stack,
477
466
  timestamp: new Date().toISOString(),
478
467
  platform: isBrowser ? "javascript" : "node",
468
+ sdk: getSdkMetadata(),
479
469
  };
480
-
481
- // Add release if set
470
+
471
+ const replay = flushReplayForEvent(DSN, event.event_id);
472
+ if (replay) {
473
+ event.replay_id = replay.replay_id;
474
+ event.session_id = replay.session_id;
475
+ }
476
+
482
477
  if (RELEASE) {
483
478
  event.release = RELEASE;
484
479
  }
485
-
486
- // Add context
480
+
487
481
  if (context.user) {
488
482
  event.user = context.user;
489
483
  }
@@ -496,11 +490,11 @@ export function captureException(error, context = {}) {
496
490
  if (context.extra) {
497
491
  event.extra = context.extra;
498
492
  }
499
- // In browser, auto-populate basic request + URL tags if not provided
493
+
500
494
  if (isBrowser) {
501
- const url = typeof window !== "undefined" ? window.location.href : undefined;
502
- const referrer =
503
- typeof document !== "undefined" ? document.referrer || undefined : undefined;
495
+ const page = getPageContext();
496
+ const url = page?.url;
497
+ const referrer = page?.referrer;
504
498
  if (!event.request && url) {
505
499
  event.request = {
506
500
  url,
@@ -512,38 +506,31 @@ export function captureException(error, context = {}) {
512
506
  }
513
507
  event.tags = {
514
508
  ...(event.tags || {}),
515
- url: url || (event.tags && event.tags.url),
516
- referrer: referrer || (event.tags && event.tags.referrer),
509
+ url: url || event.tags?.url,
510
+ referrer: referrer || event.tags?.referrer,
511
+ page_title: page?.title || event.tags?.page_title,
517
512
  };
518
513
  }
519
- // Attach runtime + environment contexts (Sentry-like)
520
- const runtime = getRuntimeContext();
521
- const browser = getBrowserContext();
522
- const os = getOsContext();
523
- const device = getDeviceContext();
524
514
 
525
- event.contexts = {
526
- ...(context.contexts || {}),
527
- ...(runtime ? { runtime } : {}),
528
- ...(browser ? { browser } : {}),
529
- ...(os ? { os } : {}),
530
- ...(device ? { device } : {}),
531
- };
515
+ event.contexts = await buildEventContexts(context);
516
+ const replayContext = getReplayContext();
517
+ if (replayContext) {
518
+ event.contexts.replay = replayContext;
519
+ }
532
520
 
533
- // Attach breadcrumbs (copied snapshot so later mutations don't affect this event)
534
521
  const bcs = getBreadcrumbsSnapshot();
535
522
  if (bcs.length > 0) {
536
523
  event.breadcrumbs = bcs;
537
524
  }
538
-
525
+
539
526
  if (DEBUG) {
540
527
  console.log("WatchForge SDK - Capturing exception:", event);
541
528
  }
542
-
529
+
543
530
  sendEvent(DSN, event);
544
531
  }
545
532
 
546
- export function captureMessage(message, level = "info", context = {}) {
533
+ export async function captureMessage(message, level = "info", context = {}) {
547
534
  if (!DSN) return;
548
535
 
549
536
  const event = {
@@ -553,14 +540,13 @@ export function captureMessage(message, level = "info", context = {}) {
553
540
  message,
554
541
  timestamp: new Date().toISOString(),
555
542
  platform: isBrowser ? "javascript" : "node",
543
+ sdk: getSdkMetadata(),
556
544
  };
557
-
558
- // Add release if set
545
+
559
546
  if (RELEASE) {
560
547
  event.release = RELEASE;
561
548
  }
562
-
563
- // Add context
549
+
564
550
  if (context.user) {
565
551
  event.user = context.user;
566
552
  }
@@ -573,10 +559,11 @@ export function captureMessage(message, level = "info", context = {}) {
573
559
  if (context.extra) {
574
560
  event.extra = context.extra;
575
561
  }
562
+
576
563
  if (isBrowser) {
577
- const url = typeof window !== "undefined" ? window.location.href : undefined;
578
- const referrer =
579
- typeof document !== "undefined" ? document.referrer || undefined : undefined;
564
+ const page = getPageContext();
565
+ const url = page?.url;
566
+ const referrer = page?.referrer;
580
567
  if (!event.request && url) {
581
568
  event.request = {
582
569
  url,
@@ -588,33 +575,23 @@ export function captureMessage(message, level = "info", context = {}) {
588
575
  }
589
576
  event.tags = {
590
577
  ...(event.tags || {}),
591
- url: url || (event.tags && event.tags.url),
592
- referrer: referrer || (event.tags && event.tags.referrer),
578
+ url: url || event.tags?.url,
579
+ referrer: referrer || event.tags?.referrer,
580
+ page_title: page?.title || event.tags?.page_title,
593
581
  };
594
582
  }
595
- // Attach runtime + environment contexts
596
- const runtime = getRuntimeContext();
597
- const browser = getBrowserContext();
598
- const os = getOsContext();
599
- const device = getDeviceContext();
600
583
 
601
- event.contexts = {
602
- ...(context.contexts || {}),
603
- ...(runtime ? { runtime } : {}),
604
- ...(browser ? { browser } : {}),
605
- ...(os ? { os } : {}),
606
- ...(device ? { device } : {}),
607
- };
584
+ event.contexts = await buildEventContexts(context);
608
585
 
609
586
  const bcs = getBreadcrumbsSnapshot();
610
587
  if (bcs.length > 0) {
611
588
  event.breadcrumbs = bcs;
612
589
  }
613
-
590
+
614
591
  if (DEBUG) {
615
592
  console.log("WatchForge SDK - Capturing message:", event);
616
593
  }
617
-
594
+
618
595
  sendEvent(DSN, event);
619
596
  }
620
597
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Event context builders (browser, Node, performance).
3
+ */
4
+
5
+ const isBrowser = typeof window !== "undefined";
6
+ const isNode =
7
+ typeof process !== "undefined" && process.versions && process.versions.node;
8
+
9
+ export const SDK_NAME = "@watchforge/browser";
10
+ export const SDK_VERSION = "0.1.1";
11
+
12
+ export function getSdkMetadata() {
13
+ return {
14
+ name: SDK_NAME,
15
+ version: SDK_VERSION,
16
+ };
17
+ }
18
+
19
+ export function getPageContext() {
20
+ if (!isBrowser) return null;
21
+
22
+ const loc = window.location;
23
+ return {
24
+ url: loc.href,
25
+ path: loc.pathname,
26
+ search: loc.search,
27
+ hash: loc.hash,
28
+ referrer: typeof document !== "undefined" ? document.referrer || null : null,
29
+ title: typeof document !== "undefined" ? document.title || null : null,
30
+ };
31
+ }
32
+
33
+ export function getPerformanceContext() {
34
+ if (!isBrowser || typeof performance === "undefined") return null;
35
+
36
+ const ctx = {};
37
+
38
+ try {
39
+ const nav = performance.getEntriesByType("navigation")[0];
40
+ if (nav) {
41
+ ctx.navigation = {
42
+ type: nav.type,
43
+ dom_content_loaded_ms: Math.round(nav.domContentLoadedEventEnd),
44
+ load_event_ms: Math.round(nav.loadEventEnd),
45
+ dom_interactive_ms: Math.round(nav.domInteractive),
46
+ response_end_ms: Math.round(nav.responseEnd),
47
+ transfer_size: nav.transferSize ?? null,
48
+ };
49
+ }
50
+ } catch {
51
+ // ignore
52
+ }
53
+
54
+ try {
55
+ const resources = performance
56
+ .getEntriesByType("resource")
57
+ .slice(-20)
58
+ .map((r) => ({
59
+ name: r.name?.slice(0, 200),
60
+ initiator_type: r.initiatorType,
61
+ duration_ms: Math.round(r.duration),
62
+ transfer_size: r.transferSize ?? null,
63
+ }));
64
+ if (resources.length) {
65
+ ctx.resources = resources;
66
+ }
67
+ } catch {
68
+ // ignore
69
+ }
70
+
71
+ return Object.keys(ctx).length ? ctx : null;
72
+ }
73
+
74
+ let nodeOsPromise = null;
75
+
76
+ async function getNodeOs() {
77
+ if (!isNode) return null;
78
+ if (!nodeOsPromise) {
79
+ nodeOsPromise = import("os").catch(() => null);
80
+ }
81
+ return nodeOsPromise;
82
+ }
83
+
84
+ export async function getNodeServerContext() {
85
+ if (!isNode) return null;
86
+
87
+ const os = await getNodeOs();
88
+ if (!os) return null;
89
+
90
+ const mem = process.memoryUsage();
91
+
92
+ return {
93
+ name: os.hostname(),
94
+ os: {
95
+ name: os.platform(),
96
+ version: os.release(),
97
+ arch: os.arch(),
98
+ },
99
+ runtime: {
100
+ name: "node",
101
+ version: process.version,
102
+ },
103
+ process: {
104
+ pid: process.pid,
105
+ uptime_seconds: Math.round(process.uptime()),
106
+ memory_rss_mb: Math.round(mem.rss / 1024 / 1024),
107
+ memory_heap_used_mb: Math.round(mem.heapUsed / 1024 / 1024),
108
+ },
109
+ };
110
+ }
package/src/express.js CHANGED
@@ -13,6 +13,16 @@
13
13
 
14
14
  import { captureException } from "./client.js";
15
15
 
16
+ /**
17
+ * Optional first middleware — records request start time for duration_ms on errors.
18
+ */
19
+ export function expressRequestMiddleware() {
20
+ return function (req, _res, next) {
21
+ req._watchforgeStart = Date.now();
22
+ next();
23
+ };
24
+ }
25
+
16
26
  export function expressMiddleware() {
17
27
  return function (err, req, res, next) {
18
28
  // Extract IP address
@@ -45,17 +55,30 @@ export function expressMiddleware() {
45
55
  ip: ip,
46
56
  };
47
57
 
48
- // Capture exception with rich API-level context
49
- captureException(err, {
58
+ const durationMs =
59
+ typeof req._watchforgeStart === "number"
60
+ ? Date.now() - req._watchforgeStart
61
+ : null;
62
+
63
+ void captureException(err, {
50
64
  user,
51
65
  request,
52
66
  tags: {
53
67
  framework: "express",
54
68
  route: req.route?.path || req.path,
55
69
  status_code: res && res.statusCode,
70
+ ...(durationMs != null ? { duration_ms: durationMs } : {}),
56
71
  },
57
72
  extra: {
58
73
  params: req.params || {},
74
+ ...(durationMs != null ? { duration_ms: durationMs } : {}),
75
+ },
76
+ contexts: {
77
+ express: {
78
+ route: req.route?.path || req.path,
79
+ base_url: req.baseUrl || null,
80
+ duration_ms: durationMs,
81
+ },
59
82
  },
60
83
  });
61
84
 
package/src/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // Export main functions
2
- export { register, captureException, captureMessage } from "./client.js";
2
+ export {
3
+ register,
4
+ captureException,
5
+ captureMessage,
6
+ addBreadcrumb,
7
+ } from "./client.js";
3
8
 
4
9
  // Export deprecated init for backward compatibility
5
10
  export { init } from "./client.js";
@@ -8,5 +13,5 @@ export { init } from "./client.js";
8
13
  export { startTransaction, getCurrentTransaction, finishTransaction, Transaction, Span } from "./tracing.js";
9
14
 
10
15
  // Export framework integrations
11
- export { expressMiddleware } from "./express.js";
16
+ export { expressMiddleware, expressRequestMiddleware } from "./express.js";
12
17
  export { ErrorBoundary } from "./react.js";
package/src/react.js CHANGED
@@ -26,12 +26,18 @@ export class ErrorBoundary extends React.Component {
26
26
  }
27
27
 
28
28
  componentDidCatch(error, errorInfo) {
29
- captureException(error, {
29
+ void captureException(error, {
30
30
  extra: {
31
31
  componentStack: errorInfo.componentStack,
32
32
  },
33
33
  tags: {
34
- framework: 'react',
34
+ framework: "react",
35
+ },
36
+ contexts: {
37
+ react: {
38
+ framework: "react",
39
+ component_stack: errorInfo.componentStack || null,
40
+ },
35
41
  },
36
42
  });
37
43
  }
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
+ }