@watchforge/browser 0.1.9 → 0.1.11

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/bin/watchforge.js CHANGED
@@ -280,6 +280,7 @@ export default function WatchForgeInit() {
280
280
 
281
281
  function createNextGlobalError(cwd, layoutPath) {
282
282
  const appDir = path.dirname(layoutPath);
283
+ const configImport = toImportPath(appDir, getConfigPath(cwd));
283
284
  const usesTs = projectUsesTypeScript(cwd);
284
285
  const globalErrorPath = path.join(appDir, usesTs ? "global-error.tsx" : "global-error.jsx");
285
286
 
@@ -301,10 +302,12 @@ function createNextGlobalError(cwd, layoutPath) {
301
302
  const content = `"use client";
302
303
 
303
304
  import { useEffect } from "react";
304
- import { captureException } from "@watchforge/browser";
305
+ import { register, captureException } from "@watchforge/browser";
306
+ import { watchforgeConfig } from "${configImport}";
305
307
 
306
308
  export default function GlobalError(${propsSignature}) {
307
309
  useEffect(() => {
310
+ register(watchforgeConfig);
308
311
  void captureException(error, {
309
312
  tags: {
310
313
  framework: "nextjs",
@@ -347,17 +350,35 @@ function createNextInstrumentation(cwd, layoutPath) {
347
350
  const configImport = toImportPath(path.dirname(instrumentationPath), getConfigPath(cwd));
348
351
 
349
352
  if (fileExists(instrumentationPath)) {
350
- const content = fs.readFileSync(instrumentationPath, "utf8");
353
+ let content = fs.readFileSync(instrumentationPath, "utf8");
354
+ if (content.includes("onRequestError")) {
355
+ log(`${path.relative(cwd, instrumentationPath)} already contains WatchForge request error capture`);
356
+ return;
357
+ }
358
+
351
359
  if (content.includes("@watchforge/browser/next/server")) {
352
- log(`${path.relative(cwd, instrumentationPath)} already contains WatchForge setup`);
360
+ content = content.replace(
361
+ /import\s*\{\s*register\s+as\s+registerWatchForge\s*\}\s*from\s*"@watchforge\/browser\/next\/server";/,
362
+ 'import { register as registerWatchForge, onRequestError as watchForgeOnRequestError } from "@watchforge/browser/next/server";'
363
+ );
364
+ if (!content.includes("watchForgeOnRequestError")) {
365
+ content = `import { onRequestError as watchForgeOnRequestError } from "@watchforge/browser/next/server";\n${content}`;
366
+ }
367
+ content = `${content.trim()}\n\nexport const onRequestError = watchForgeOnRequestError;\n`;
368
+ fs.writeFileSync(instrumentationPath, content);
369
+ log(`patched ${path.relative(cwd, instrumentationPath)} for SSR/request error capture`);
353
370
  return;
354
371
  }
372
+
355
373
  log(`skipped ${path.relative(cwd, instrumentationPath)} because it already exists`);
356
- log("add registerWatchForge(watchforgeConfig) there manually to report Next.js server runtime errors");
374
+ log("add registerWatchForge(watchforgeConfig) and onRequestError there manually");
357
375
  return;
358
376
  }
359
377
 
360
- const content = `import { register as registerWatchForge } from "@watchforge/browser/next/server";
378
+ const content = `import {
379
+ register as registerWatchForge,
380
+ onRequestError as watchForgeOnRequestError,
381
+ } from "@watchforge/browser/next/server";
361
382
  import { watchforgeConfig } from "${configImport}";
362
383
 
363
384
  export async function register() {
@@ -365,6 +386,8 @@ export async function register() {
365
386
  registerWatchForge(watchforgeConfig);
366
387
  }
367
388
  }
389
+
390
+ export const onRequestError = watchForgeOnRequestError;
368
391
  `;
369
392
 
370
393
  writeIfChanged(instrumentationPath, content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchforge/browser",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "main": "./src/index.js",
5
5
  "types": "./src/index.d.ts",
6
6
  "description": "WatchForge JavaScript SDK for browser JavaScript, Next.js, React, Node.js, and Express.js",
package/src/client.js CHANGED
@@ -19,6 +19,7 @@ let DSN = null;
19
19
  let APP_ENV = "production";
20
20
  let RELEASE = null;
21
21
  let DEBUG = false;
22
+ let CAPTURE_CONSOLE_ERRORS = true;
22
23
 
23
24
  // Detect environment
24
25
  const isBrowser = typeof window !== "undefined";
@@ -28,6 +29,8 @@ const isNode =
28
29
  // In-memory breadcrumb buffer (shared for all events in this process / page)
29
30
  const MAX_BREADCRUMBS = 100;
30
31
  let breadcrumbs = [];
32
+ let browserInstrumentationInstalled = false;
33
+ let nodeInstrumentationInstalled = false;
31
34
 
32
35
  export function addBreadcrumb(breadcrumb) {
33
36
  const entry = {
@@ -48,6 +51,10 @@ function getBreadcrumbsSnapshot() {
48
51
  return breadcrumbs.slice();
49
52
  }
50
53
 
54
+ function isWatchForgeInternalMessage(message) {
55
+ return String(message || "").includes("WatchForge SDK");
56
+ }
57
+
51
58
  // Simple browser environment detectors (best-effort, not 100% accurate)
52
59
  function getBrowserContext() {
53
60
  if (!isBrowser) return null;
@@ -150,6 +157,8 @@ function getRuntimeContext() {
150
157
 
151
158
  function setupBrowserInstrumentation() {
152
159
  if (!isBrowser) return;
160
+ if (browserInstrumentationInstalled) return;
161
+ browserInstrumentationInstalled = true;
153
162
 
154
163
  // Global error handlers (already existed but keep here with breadcrumbs)
155
164
  window.onerror = function (msg, url, line, col, error) {
@@ -180,13 +189,42 @@ function setupBrowserInstrumentation() {
180
189
  const original = console[level].bind(console);
181
190
  console[level] = (...args) => {
182
191
  try {
192
+ const message = args.map(String).join(" ");
183
193
  addBreadcrumb({
184
194
  type: "log",
185
195
  level,
186
196
  category: "console",
187
- message: args.map(String).join(" "),
197
+ message,
188
198
  data: {},
189
199
  });
200
+
201
+ if (
202
+ level === "error" &&
203
+ CAPTURE_CONSOLE_ERRORS &&
204
+ !isWatchForgeInternalMessage(message)
205
+ ) {
206
+ const errorArg = args.find((arg) => arg instanceof Error);
207
+ const error =
208
+ errorArg ||
209
+ new Error(message || "console.error");
210
+ setTimeout(() => {
211
+ void captureException(error, {
212
+ tags: {
213
+ handled: true,
214
+ mechanism: "console.error",
215
+ },
216
+ contexts: {
217
+ console: {
218
+ arguments: args.map((arg) =>
219
+ arg instanceof Error
220
+ ? { name: arg.name, message: arg.message, stack: arg.stack }
221
+ : String(arg)
222
+ ),
223
+ },
224
+ },
225
+ });
226
+ }, 0);
227
+ }
190
228
  } catch (_) {
191
229
  // best-effort, never break console
192
230
  }
@@ -389,11 +427,13 @@ export function register({
389
427
  blockClass = "rr-block",
390
428
  ignoreClass = "rr-ignore",
391
429
  maskTextClass = "rr-mask",
430
+ captureConsoleErrors = true,
392
431
  }) {
393
432
  DSN = dsn;
394
433
  APP_ENV = app_env;
395
434
  RELEASE = release;
396
435
  DEBUG = debug;
436
+ CAPTURE_CONSOLE_ERRORS = Boolean(captureConsoleErrors);
397
437
 
398
438
  // Initialize tracing
399
439
  initTracing(dsn, app_env, debug);
@@ -433,6 +473,9 @@ export function register({
433
473
 
434
474
  // Node.js: Set up process error handlers
435
475
  if (isNode) {
476
+ if (nodeInstrumentationInstalled) return;
477
+ nodeInstrumentationInstalled = true;
478
+
436
479
  process.on("uncaughtException", (error) => {
437
480
  void captureException(error);
438
481
  });
package/src/contexts.js CHANGED
@@ -7,7 +7,7 @@ const isNode =
7
7
  typeof process !== "undefined" && process.versions && process.versions.node;
8
8
 
9
9
  export const SDK_NAME = "@watchforge/browser";
10
- export const SDK_VERSION = "0.1.1";
10
+ export const SDK_VERSION = "0.1.11";
11
11
 
12
12
  export function getSdkMetadata() {
13
13
  return {
package/src/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface WatchForgeRegisterOptions {
9
9
  blockClass?: string;
10
10
  ignoreClass?: string;
11
11
  maskTextClass?: string;
12
+ captureConsoleErrors?: boolean;
12
13
  }
13
14
 
14
15
  export interface WatchForgeCaptureContext {
@@ -5,6 +5,27 @@ import type {
5
5
 
6
6
  export function register(options: WatchForgeRegisterOptions): void;
7
7
 
8
+ export type NextRequestErrorContext = {
9
+ routerKind: "Pages Router" | "App Router";
10
+ routePath: string;
11
+ routeType: "render" | "route" | "action" | "middleware";
12
+ renderSource?:
13
+ | "react-server-components"
14
+ | "react-server-components-payload"
15
+ | "server-rendering";
16
+ revalidateReason?: "on-demand" | "stale";
17
+ };
18
+
19
+ export function onRequestError(
20
+ error: unknown,
21
+ request: Readonly<{
22
+ path: string;
23
+ method: string;
24
+ headers: Record<string, string | string[] | undefined>;
25
+ }>,
26
+ context: Readonly<NextRequestErrorContext>
27
+ ): void | Promise<void>;
28
+
8
29
  export function captureException(
9
30
  error: unknown,
10
31
  context?: WatchForgeCaptureContext
@@ -1,10 +1,70 @@
1
1
  import {
2
2
  captureException,
3
3
  captureMessage,
4
- register,
4
+ register as registerClient,
5
5
  } from "./client.js";
6
6
 
7
- export { captureException, captureMessage, register };
7
+ let registeredOptions = null;
8
+
9
+ export function register(options) {
10
+ registeredOptions = options;
11
+ registerClient(options);
12
+ }
13
+
14
+ export { captureException, captureMessage };
15
+
16
+ function buildRequestUrl(request) {
17
+ if (!request?.path) return "";
18
+ const path = request.path.startsWith("/") ? request.path : `/${request.path}`;
19
+ return path;
20
+ }
21
+
22
+ export async function onRequestError(error, request, context) {
23
+ if (!registeredOptions) return;
24
+
25
+ await captureException(error, {
26
+ request: {
27
+ url: buildRequestUrl(request),
28
+ method: request?.method || "GET",
29
+ headers: sanitizeRequestHeaders(request?.headers),
30
+ },
31
+ tags: {
32
+ framework: "nextjs",
33
+ runtime: "server",
34
+ router: context?.routerKind || "unknown",
35
+ route_type: context?.routeType || "unknown",
36
+ },
37
+ contexts: {
38
+ nextjs: {
39
+ on_request_error: true,
40
+ router_kind: context?.routerKind || null,
41
+ route_path: context?.routePath || null,
42
+ route_type: context?.routeType || null,
43
+ render_source: context?.renderSource || null,
44
+ },
45
+ },
46
+ });
47
+ }
48
+
49
+ function sanitizeRequestHeaders(headers) {
50
+ if (!headers) return {};
51
+
52
+ const sensitive = new Set([
53
+ "authorization",
54
+ "cookie",
55
+ "set-cookie",
56
+ "x-api-key",
57
+ "x-csrftoken",
58
+ ]);
59
+ const entries =
60
+ typeof headers.entries === "function"
61
+ ? Array.from(headers.entries())
62
+ : Object.entries(headers);
63
+
64
+ return Object.fromEntries(
65
+ entries.filter(([key]) => !sensitive.has(String(key).toLowerCase()))
66
+ );
67
+ }
8
68
 
9
69
  function sanitizeHeaders(headers) {
10
70
  if (!headers || typeof headers.entries !== "function") return {};
package/src/replay.js CHANGED
@@ -3,6 +3,8 @@ import { sendReplay } from "./transport.js";
3
3
  const isBrowser = typeof window !== "undefined";
4
4
  const MAX_BUFFER_MS = 60 * 1000;
5
5
  const MAX_EVENTS = 500;
6
+ const RRWEB_FULL_SNAPSHOT = 2;
7
+ const RRWEB_META = 4;
6
8
 
7
9
  let options = {
8
10
  replaysSessionSampleRate: 0,
@@ -37,9 +39,43 @@ function shouldSample(rate) {
37
39
 
38
40
  function trimBuffer(now = Date.now()) {
39
41
  events = events.filter((event) => now - event.timestamp <= MAX_BUFFER_MS);
40
- if (events.length > MAX_EVENTS) {
41
- events = events.slice(-MAX_EVENTS);
42
+ if (events.length <= MAX_EVENTS) return;
43
+
44
+ const tail = events.slice(-MAX_EVENTS);
45
+ if (tail.some((event) => event.type === RRWEB_FULL_SNAPSHOT)) {
46
+ events = tail;
47
+ return;
48
+ }
49
+
50
+ const fullSnapshotIndex = findLastEventIndex(events, RRWEB_FULL_SNAPSHOT);
51
+ if (fullSnapshotIndex === -1) {
52
+ events = tail;
53
+ return;
54
+ }
55
+
56
+ const metaIndex = findLastEventIndex(
57
+ events.slice(0, fullSnapshotIndex),
58
+ RRWEB_META
59
+ );
60
+ const anchorEvents = [
61
+ ...(metaIndex >= 0 ? [events[metaIndex]] : []),
62
+ events[fullSnapshotIndex],
63
+ ];
64
+ const remainingSlots = Math.max(MAX_EVENTS - anchorEvents.length, 0);
65
+ const recentEvents = events
66
+ .slice(fullSnapshotIndex + 1)
67
+ .slice(-remainingSlots);
68
+
69
+ events = [...anchorEvents, ...recentEvents].sort(
70
+ (a, b) => (a.timestamp || 0) - (b.timestamp || 0)
71
+ );
72
+ }
73
+
74
+ function findLastEventIndex(list, type) {
75
+ for (let i = list.length - 1; i >= 0; i--) {
76
+ if (list[i]?.type === type) return i;
42
77
  }
78
+ return -1;
43
79
  }
44
80
 
45
81
  export async function initReplay(config = {}) {
@@ -115,7 +151,7 @@ export function flushReplayForEvent(dsn, eventId) {
115
151
  events,
116
152
  sdk: {
117
153
  name: "@watchforge/browser",
118
- version: "0.1.3",
154
+ version: "0.1.11",
119
155
  },
120
156
  };
121
157
 
package/src/tracing.js CHANGED
@@ -118,7 +118,7 @@ class Transaction {
118
118
  request: this.request,
119
119
  platform: typeof window !== "undefined" ? "javascript" : "node",
120
120
  sdk_name: "watchforge-javascript",
121
- sdk_version: "0.1.0",
121
+ sdk_version: "0.1.11",
122
122
  };
123
123
 
124
124
  if (DEBUG) {