@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 +28 -5
- package/package.json +1 -1
- package/src/client.js +44 -1
- package/src/contexts.js +1 -1
- package/src/index.d.ts +1 -0
- package/src/next-server.d.ts +21 -0
- package/src/next-server.js +62 -2
- package/src/replay.js +39 -3
- package/src/tracing.js +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
374
|
+
log("add registerWatchForge(watchforgeConfig) and onRequestError there manually");
|
|
357
375
|
return;
|
|
358
376
|
}
|
|
359
377
|
|
|
360
|
-
const content = `import {
|
|
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
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
|
|
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.
|
|
10
|
+
export const SDK_VERSION = "0.1.11";
|
|
11
11
|
|
|
12
12
|
export function getSdkMetadata() {
|
|
13
13
|
return {
|
package/src/index.d.ts
CHANGED
package/src/next-server.d.ts
CHANGED
|
@@ -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
|
package/src/next-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
|
-
|
|
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.
|
|
154
|
+
version: "0.1.11",
|
|
119
155
|
},
|
|
120
156
|
};
|
|
121
157
|
|
package/src/tracing.js
CHANGED