@watchforge/browser 0.1.10 → 0.1.12
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 +47 -19
- package/package.json +1 -1
- package/src/client.js +66 -3
- package/src/contexts.js +1 -1
- package/src/index.d.ts +2 -0
- package/src/next-server.d.ts +21 -0
- package/src/next-server.js +62 -2
- package/src/replay.js +22 -1
- package/src/stacktrace.js +227 -9
- package/src/tracing.js +1 -1
package/bin/watchforge.js
CHANGED
|
@@ -214,6 +214,7 @@ function createConfig(cwd, args) {
|
|
|
214
214
|
replaysOnErrorSampleRate: ${Number(args.replaysOnError)},
|
|
215
215
|
replaysSessionSampleRate: ${Number(args.replaysSession)},
|
|
216
216
|
maskAllInputs: true,
|
|
217
|
+
projectRoot: process.env.INIT_CWD || process.env.PWD || "",
|
|
217
218
|
};
|
|
218
219
|
`;
|
|
219
220
|
writeIfChanged(configPath, content);
|
|
@@ -280,6 +281,7 @@ export default function WatchForgeInit() {
|
|
|
280
281
|
|
|
281
282
|
function createNextGlobalError(cwd, layoutPath) {
|
|
282
283
|
const appDir = path.dirname(layoutPath);
|
|
284
|
+
const configImport = toImportPath(appDir, getConfigPath(cwd));
|
|
283
285
|
const usesTs = projectUsesTypeScript(cwd);
|
|
284
286
|
const globalErrorPath = path.join(appDir, usesTs ? "global-error.tsx" : "global-error.jsx");
|
|
285
287
|
|
|
@@ -301,25 +303,31 @@ function createNextGlobalError(cwd, layoutPath) {
|
|
|
301
303
|
const content = `"use client";
|
|
302
304
|
|
|
303
305
|
import { useEffect } from "react";
|
|
304
|
-
import { captureException } from "@watchforge/browser";
|
|
306
|
+
import { register, captureException } from "@watchforge/browser";
|
|
307
|
+
import { watchforgeConfig } from "${configImport}";
|
|
305
308
|
|
|
306
309
|
export default function GlobalError(${propsSignature}) {
|
|
307
310
|
useEffect(() => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
digest: error.digest,
|
|
315
|
-
},
|
|
316
|
-
contexts: {
|
|
317
|
-
nextjs: {
|
|
318
|
-
error_boundary: "global-error",
|
|
319
|
-
digest: error.digest || null,
|
|
311
|
+
register(watchforgeConfig);
|
|
312
|
+
const timer = window.setTimeout(() => {
|
|
313
|
+
void captureException(error, {
|
|
314
|
+
tags: {
|
|
315
|
+
framework: "nextjs",
|
|
316
|
+
runtime: "error-boundary",
|
|
320
317
|
},
|
|
321
|
-
|
|
322
|
-
|
|
318
|
+
extra: {
|
|
319
|
+
digest: error.digest,
|
|
320
|
+
},
|
|
321
|
+
contexts: {
|
|
322
|
+
nextjs: {
|
|
323
|
+
error_boundary: "global-error",
|
|
324
|
+
digest: error.digest || null,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}, 100);
|
|
329
|
+
|
|
330
|
+
return () => window.clearTimeout(timer);
|
|
323
331
|
}, [error]);
|
|
324
332
|
|
|
325
333
|
return (
|
|
@@ -347,17 +355,35 @@ function createNextInstrumentation(cwd, layoutPath) {
|
|
|
347
355
|
const configImport = toImportPath(path.dirname(instrumentationPath), getConfigPath(cwd));
|
|
348
356
|
|
|
349
357
|
if (fileExists(instrumentationPath)) {
|
|
350
|
-
|
|
358
|
+
let content = fs.readFileSync(instrumentationPath, "utf8");
|
|
359
|
+
if (content.includes("onRequestError")) {
|
|
360
|
+
log(`${path.relative(cwd, instrumentationPath)} already contains WatchForge request error capture`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
351
364
|
if (content.includes("@watchforge/browser/next/server")) {
|
|
352
|
-
|
|
365
|
+
content = content.replace(
|
|
366
|
+
/import\s*\{\s*register\s+as\s+registerWatchForge\s*\}\s*from\s*"@watchforge\/browser\/next\/server";/,
|
|
367
|
+
'import { register as registerWatchForge, onRequestError as watchForgeOnRequestError } from "@watchforge/browser/next/server";'
|
|
368
|
+
);
|
|
369
|
+
if (!content.includes("watchForgeOnRequestError")) {
|
|
370
|
+
content = `import { onRequestError as watchForgeOnRequestError } from "@watchforge/browser/next/server";\n${content}`;
|
|
371
|
+
}
|
|
372
|
+
content = `${content.trim()}\n\nexport const onRequestError = watchForgeOnRequestError;\n`;
|
|
373
|
+
fs.writeFileSync(instrumentationPath, content);
|
|
374
|
+
log(`patched ${path.relative(cwd, instrumentationPath)} for SSR/request error capture`);
|
|
353
375
|
return;
|
|
354
376
|
}
|
|
377
|
+
|
|
355
378
|
log(`skipped ${path.relative(cwd, instrumentationPath)} because it already exists`);
|
|
356
|
-
log("add registerWatchForge(watchforgeConfig) there manually
|
|
379
|
+
log("add registerWatchForge(watchforgeConfig) and onRequestError there manually");
|
|
357
380
|
return;
|
|
358
381
|
}
|
|
359
382
|
|
|
360
|
-
const content = `import {
|
|
383
|
+
const content = `import {
|
|
384
|
+
register as registerWatchForge,
|
|
385
|
+
onRequestError as watchForgeOnRequestError,
|
|
386
|
+
} from "@watchforge/browser/next/server";
|
|
361
387
|
import { watchforgeConfig } from "${configImport}";
|
|
362
388
|
|
|
363
389
|
export async function register() {
|
|
@@ -365,6 +391,8 @@ export async function register() {
|
|
|
365
391
|
registerWatchForge(watchforgeConfig);
|
|
366
392
|
}
|
|
367
393
|
}
|
|
394
|
+
|
|
395
|
+
export const onRequestError = watchForgeOnRequestError;
|
|
368
396
|
`;
|
|
369
397
|
|
|
370
398
|
writeIfChanged(instrumentationPath, content);
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { sendEvent, parseDsn } from "./transport.js";
|
|
|
2
2
|
import {
|
|
3
3
|
buildStacktraceFromError,
|
|
4
4
|
enrichStacktraceAsync,
|
|
5
|
+
setProjectRoot,
|
|
5
6
|
} from "./stacktrace.js";
|
|
6
7
|
import {
|
|
7
8
|
getSdkMetadata,
|
|
@@ -13,12 +14,14 @@ import {
|
|
|
13
14
|
flushReplayForEvent,
|
|
14
15
|
getReplayContext,
|
|
15
16
|
initReplay,
|
|
17
|
+
waitForReplayReady,
|
|
16
18
|
} from "./replay.js";
|
|
17
19
|
|
|
18
20
|
let DSN = null;
|
|
19
21
|
let APP_ENV = "production";
|
|
20
22
|
let RELEASE = null;
|
|
21
23
|
let DEBUG = false;
|
|
24
|
+
let CAPTURE_CONSOLE_ERRORS = true;
|
|
22
25
|
|
|
23
26
|
// Detect environment
|
|
24
27
|
const isBrowser = typeof window !== "undefined";
|
|
@@ -28,6 +31,9 @@ const isNode =
|
|
|
28
31
|
// In-memory breadcrumb buffer (shared for all events in this process / page)
|
|
29
32
|
const MAX_BREADCRUMBS = 100;
|
|
30
33
|
let breadcrumbs = [];
|
|
34
|
+
let browserInstrumentationInstalled = false;
|
|
35
|
+
let nodeInstrumentationInstalled = false;
|
|
36
|
+
let replayInitPromise = null;
|
|
31
37
|
|
|
32
38
|
export function addBreadcrumb(breadcrumb) {
|
|
33
39
|
const entry = {
|
|
@@ -48,6 +54,10 @@ function getBreadcrumbsSnapshot() {
|
|
|
48
54
|
return breadcrumbs.slice();
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
function isWatchForgeInternalMessage(message) {
|
|
58
|
+
return String(message || "").includes("WatchForge SDK");
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
// Simple browser environment detectors (best-effort, not 100% accurate)
|
|
52
62
|
function getBrowserContext() {
|
|
53
63
|
if (!isBrowser) return null;
|
|
@@ -150,6 +160,8 @@ function getRuntimeContext() {
|
|
|
150
160
|
|
|
151
161
|
function setupBrowserInstrumentation() {
|
|
152
162
|
if (!isBrowser) return;
|
|
163
|
+
if (browserInstrumentationInstalled) return;
|
|
164
|
+
browserInstrumentationInstalled = true;
|
|
153
165
|
|
|
154
166
|
// Global error handlers (already existed but keep here with breadcrumbs)
|
|
155
167
|
window.onerror = function (msg, url, line, col, error) {
|
|
@@ -180,13 +192,42 @@ function setupBrowserInstrumentation() {
|
|
|
180
192
|
const original = console[level].bind(console);
|
|
181
193
|
console[level] = (...args) => {
|
|
182
194
|
try {
|
|
195
|
+
const message = args.map(String).join(" ");
|
|
183
196
|
addBreadcrumb({
|
|
184
197
|
type: "log",
|
|
185
198
|
level,
|
|
186
199
|
category: "console",
|
|
187
|
-
message
|
|
200
|
+
message,
|
|
188
201
|
data: {},
|
|
189
202
|
});
|
|
203
|
+
|
|
204
|
+
if (
|
|
205
|
+
level === "error" &&
|
|
206
|
+
CAPTURE_CONSOLE_ERRORS &&
|
|
207
|
+
!isWatchForgeInternalMessage(message)
|
|
208
|
+
) {
|
|
209
|
+
const errorArg = args.find((arg) => arg instanceof Error);
|
|
210
|
+
const error =
|
|
211
|
+
errorArg ||
|
|
212
|
+
new Error(message || "console.error");
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
void captureException(error, {
|
|
215
|
+
tags: {
|
|
216
|
+
handled: true,
|
|
217
|
+
mechanism: "console.error",
|
|
218
|
+
},
|
|
219
|
+
contexts: {
|
|
220
|
+
console: {
|
|
221
|
+
arguments: args.map((arg) =>
|
|
222
|
+
arg instanceof Error
|
|
223
|
+
? { name: arg.name, message: arg.message, stack: arg.stack }
|
|
224
|
+
: String(arg)
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}, 0);
|
|
230
|
+
}
|
|
190
231
|
} catch (_) {
|
|
191
232
|
// best-effort, never break console
|
|
192
233
|
}
|
|
@@ -389,11 +430,18 @@ export function register({
|
|
|
389
430
|
blockClass = "rr-block",
|
|
390
431
|
ignoreClass = "rr-ignore",
|
|
391
432
|
maskTextClass = "rr-mask",
|
|
433
|
+
captureConsoleErrors = true,
|
|
434
|
+
projectRoot = null,
|
|
392
435
|
}) {
|
|
393
436
|
DSN = dsn;
|
|
394
437
|
APP_ENV = app_env;
|
|
395
438
|
RELEASE = release;
|
|
396
439
|
DEBUG = debug;
|
|
440
|
+
CAPTURE_CONSOLE_ERRORS = Boolean(captureConsoleErrors);
|
|
441
|
+
setProjectRoot(
|
|
442
|
+
projectRoot ||
|
|
443
|
+
(isNode ? process.env.WATCHFORGE_PROJECT_ROOT || process.env.INIT_CWD : null)
|
|
444
|
+
);
|
|
397
445
|
|
|
398
446
|
// Initialize tracing
|
|
399
447
|
initTracing(dsn, app_env, debug);
|
|
@@ -420,7 +468,7 @@ export function register({
|
|
|
420
468
|
// Browser: Set up full instrumentation (errors, breadcrumbs, HTTP, navigation)
|
|
421
469
|
if (isBrowser) {
|
|
422
470
|
setupBrowserInstrumentation();
|
|
423
|
-
initReplay({
|
|
471
|
+
replayInitPromise = initReplay({
|
|
424
472
|
replaysSessionSampleRate,
|
|
425
473
|
replaysOnErrorSampleRate,
|
|
426
474
|
maskAllInputs,
|
|
@@ -433,6 +481,9 @@ export function register({
|
|
|
433
481
|
|
|
434
482
|
// Node.js: Set up process error handlers
|
|
435
483
|
if (isNode) {
|
|
484
|
+
if (nodeInstrumentationInstalled) return;
|
|
485
|
+
nodeInstrumentationInstalled = true;
|
|
486
|
+
|
|
436
487
|
process.on("uncaughtException", (error) => {
|
|
437
488
|
void captureException(error);
|
|
438
489
|
});
|
|
@@ -454,7 +505,10 @@ export async function captureException(error, context = {}) {
|
|
|
454
505
|
|
|
455
506
|
let stacktrace = buildStacktraceFromError(error);
|
|
456
507
|
if (stacktrace) {
|
|
457
|
-
stacktrace = await enrichStacktraceAsync(
|
|
508
|
+
stacktrace = await enrichStacktraceAsync(
|
|
509
|
+
stacktrace,
|
|
510
|
+
error?.message || String(error || "")
|
|
511
|
+
);
|
|
458
512
|
}
|
|
459
513
|
|
|
460
514
|
const event = {
|
|
@@ -468,6 +522,15 @@ export async function captureException(error, context = {}) {
|
|
|
468
522
|
sdk: getSdkMetadata(),
|
|
469
523
|
};
|
|
470
524
|
|
|
525
|
+
if (isBrowser && replayInitPromise) {
|
|
526
|
+
try {
|
|
527
|
+
await replayInitPromise;
|
|
528
|
+
await waitForReplayReady();
|
|
529
|
+
} catch {
|
|
530
|
+
// Replay is best-effort; never block error delivery.
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
471
534
|
const replay = flushReplayForEvent(DSN, event.event_id);
|
|
472
535
|
if (replay) {
|
|
473
536
|
event.replay_id = replay.replay_id;
|
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.12";
|
|
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
|
@@ -120,6 +120,27 @@ export async function initReplay(config = {}) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
export function waitForReplayReady(timeoutMs = 300) {
|
|
124
|
+
if (!isBrowser || !replayId || !sessionId) return Promise.resolve(false);
|
|
125
|
+
if (events.length > 0) return Promise.resolve(true);
|
|
126
|
+
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
const started = Date.now();
|
|
129
|
+
const check = () => {
|
|
130
|
+
if (events.length > 0) {
|
|
131
|
+
resolve(true);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (Date.now() - started >= timeoutMs) {
|
|
135
|
+
resolve(false);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setTimeout(check, 25);
|
|
139
|
+
};
|
|
140
|
+
check();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
export function getReplayContext() {
|
|
124
145
|
if (!isBrowser || !replayId || !sessionId) return null;
|
|
125
146
|
return {
|
|
@@ -151,7 +172,7 @@ export function flushReplayForEvent(dsn, eventId) {
|
|
|
151
172
|
events,
|
|
152
173
|
sdk: {
|
|
153
174
|
name: "@watchforge/browser",
|
|
154
|
-
version: "0.1.
|
|
175
|
+
version: "0.1.12",
|
|
155
176
|
},
|
|
156
177
|
};
|
|
157
178
|
|
package/src/stacktrace.js
CHANGED
|
@@ -60,6 +60,7 @@ export function buildStacktraceFromError(error) {
|
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const errorMessage = error?.message || "";
|
|
63
64
|
const stackLines = error.stack.split("\n");
|
|
64
65
|
const frames = [];
|
|
65
66
|
|
|
@@ -88,6 +89,7 @@ export function buildStacktraceFromError(error) {
|
|
|
88
89
|
post_context: [],
|
|
89
90
|
vars: {},
|
|
90
91
|
in_app: inApp,
|
|
92
|
+
error_message: errorMessage,
|
|
91
93
|
});
|
|
92
94
|
}
|
|
93
95
|
|
|
@@ -114,6 +116,45 @@ function applySourceContext(frame, sourceLines, lineno) {
|
|
|
114
116
|
.map((l) => l.replace(/\r$/, ""));
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
function getUndefinedIdentifier(message) {
|
|
120
|
+
if (!message || typeof message !== "string") return null;
|
|
121
|
+
const match = message.match(/^([A-Za-z_$][\w$]*) is not defined$/);
|
|
122
|
+
return match?.[1] || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function inferLineFromErrorMessage(frame, sourceLines) {
|
|
126
|
+
const identifier = getUndefinedIdentifier(frame.error_message);
|
|
127
|
+
if (!identifier || !sourceLines?.length) return null;
|
|
128
|
+
|
|
129
|
+
const tokenPattern = new RegExp(`\\b${identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
130
|
+
const functionName = frame.function && frame.function !== "<anonymous>"
|
|
131
|
+
? String(frame.function)
|
|
132
|
+
: null;
|
|
133
|
+
|
|
134
|
+
const candidates = [];
|
|
135
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
136
|
+
if (!tokenPattern.test(sourceLines[i])) continue;
|
|
137
|
+
candidates.push(i);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!candidates.length) return null;
|
|
141
|
+
|
|
142
|
+
if (functionName) {
|
|
143
|
+
const functionPattern = new RegExp(`\\b${functionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
144
|
+
let nearestFunctionLine = -1;
|
|
145
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
146
|
+
if (functionPattern.test(sourceLines[i])) {
|
|
147
|
+
nearestFunctionLine = i;
|
|
148
|
+
}
|
|
149
|
+
if (candidates.includes(i) && nearestFunctionLine !== -1) {
|
|
150
|
+
return i + 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return candidates[0] + 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
117
158
|
function normalizeSourcePath(source) {
|
|
118
159
|
if (!source) return "";
|
|
119
160
|
|
|
@@ -167,6 +208,114 @@ function getNodeModules() {
|
|
|
167
208
|
return nodeModulesPromise;
|
|
168
209
|
}
|
|
169
210
|
|
|
211
|
+
let cachedProjectRoots = null;
|
|
212
|
+
let configuredProjectRoot = null;
|
|
213
|
+
|
|
214
|
+
export function setProjectRoot(projectRoot) {
|
|
215
|
+
configuredProjectRoot = projectRoot || null;
|
|
216
|
+
cachedProjectRoots = null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function packageJsonHasNext(packageJson) {
|
|
220
|
+
return Boolean(packageJson.dependencies?.next || packageJson.devDependencies?.next);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function findProjectRoots() {
|
|
224
|
+
if (cachedProjectRoots) return cachedProjectRoots;
|
|
225
|
+
|
|
226
|
+
const mods = await getNodeModules();
|
|
227
|
+
if (!mods) {
|
|
228
|
+
cachedProjectRoots = [process.cwd()];
|
|
229
|
+
return cachedProjectRoots;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { fs, path } = mods;
|
|
233
|
+
const roots = new Set();
|
|
234
|
+
const seeds = [
|
|
235
|
+
configuredProjectRoot,
|
|
236
|
+
process.env.WATCHFORGE_PROJECT_ROOT,
|
|
237
|
+
process.env.INIT_CWD,
|
|
238
|
+
process.env.PWD,
|
|
239
|
+
process.cwd(),
|
|
240
|
+
].filter(Boolean);
|
|
241
|
+
|
|
242
|
+
for (const seed of seeds) {
|
|
243
|
+
roots.add(seed);
|
|
244
|
+
|
|
245
|
+
let dir = seed;
|
|
246
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
247
|
+
const packageJsonPath = path.join(dir, "package.json");
|
|
248
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
249
|
+
try {
|
|
250
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
251
|
+
if (packageJsonHasNext(packageJson)) {
|
|
252
|
+
roots.add(dir);
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore invalid package.json
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const parent = path.dirname(dir);
|
|
260
|
+
if (parent === dir) break;
|
|
261
|
+
dir = parent;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
cachedProjectRoots = [...roots];
|
|
266
|
+
return cachedProjectRoots;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readFileLines(fs, filePath) {
|
|
270
|
+
const stat = fs.statSync(filePath);
|
|
271
|
+
if (stat.size > MAX_SOURCE_FILE_BYTES) return null;
|
|
272
|
+
return fs.readFileSync(filePath, "utf8").split("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function findFileUnderRoot(fs, path, root, normalizedPath, basename, maxDepth = 5) {
|
|
276
|
+
const suffix = normalizedPath.replace(/^\/+/, "");
|
|
277
|
+
const directCandidate = path.join(root, suffix);
|
|
278
|
+
if (fs.existsSync(directCandidate)) {
|
|
279
|
+
return directCandidate;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
283
|
+
let scannedFiles = 0;
|
|
284
|
+
|
|
285
|
+
while (stack.length > 0 && scannedFiles < 500) {
|
|
286
|
+
const { dir, depth } = stack.pop();
|
|
287
|
+
if (depth > maxDepth) continue;
|
|
288
|
+
|
|
289
|
+
let entries = [];
|
|
290
|
+
try {
|
|
291
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
292
|
+
} catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const entryPath = path.join(dir, entry.name);
|
|
302
|
+
if (entry.isFile()) {
|
|
303
|
+
scannedFiles += 1;
|
|
304
|
+
if (entry.name === basename) {
|
|
305
|
+
const normalizedEntry = entryPath.replace(/\\/g, "/");
|
|
306
|
+
if (normalizedEntry.endsWith(suffix)) {
|
|
307
|
+
return entryPath;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} else if (entry.isDirectory()) {
|
|
311
|
+
stack.push({ dir: entryPath, depth: depth + 1 });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
170
319
|
async function readNodeSourceLines(absPath) {
|
|
171
320
|
if (!isNode || !absPath) return null;
|
|
172
321
|
|
|
@@ -185,26 +334,87 @@ async function readNodeSourceLines(absPath) {
|
|
|
185
334
|
filePath = fileURLToPath(filePath);
|
|
186
335
|
}
|
|
187
336
|
|
|
188
|
-
|
|
189
|
-
|
|
337
|
+
const normalizedPath = normalizeSourcePath(filePath);
|
|
338
|
+
const roots = await findProjectRoots();
|
|
339
|
+
const candidates = new Set();
|
|
340
|
+
|
|
341
|
+
if (path.isAbsolute(filePath)) {
|
|
342
|
+
candidates.add(filePath);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const root of roots) {
|
|
346
|
+
candidates.add(path.resolve(root, filePath));
|
|
347
|
+
candidates.add(path.resolve(root, normalizedPath));
|
|
348
|
+
|
|
349
|
+
const srcMatch = normalizedPath.match(/(?:^|\/)?(src\/.+)$/);
|
|
350
|
+
if (srcMatch) {
|
|
351
|
+
candidates.add(path.resolve(root, srcMatch[1]));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const candidate of candidates) {
|
|
356
|
+
if (!candidate || !fs.existsSync(candidate)) continue;
|
|
357
|
+
const lines = readFileLines(fs, candidate);
|
|
358
|
+
if (lines) return lines;
|
|
190
359
|
}
|
|
191
360
|
|
|
192
|
-
|
|
361
|
+
const basename = path.basename(normalizedPath);
|
|
362
|
+
for (const root of roots) {
|
|
363
|
+
const packageJsonPath = path.join(root, "package.json");
|
|
364
|
+
if (!fs.existsSync(packageJsonPath)) continue;
|
|
365
|
+
|
|
366
|
+
let isNextProject = root === process.env.INIT_CWD || root === process.env.PWD;
|
|
367
|
+
if (!isNextProject) {
|
|
368
|
+
try {
|
|
369
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
370
|
+
isNextProject = packageJsonHasNext(packageJson);
|
|
371
|
+
} catch {
|
|
372
|
+
isNextProject = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
193
375
|
|
|
194
|
-
|
|
195
|
-
|
|
376
|
+
if (!isNextProject) continue;
|
|
377
|
+
|
|
378
|
+
const discovered = findFileUnderRoot(fs, path, root, normalizedPath, basename);
|
|
379
|
+
if (discovered && fs.existsSync(discovered)) {
|
|
380
|
+
const lines = readFileLines(fs, discovered);
|
|
381
|
+
if (lines) return lines;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
196
384
|
|
|
197
|
-
|
|
198
|
-
return content.split("\n");
|
|
385
|
+
return null;
|
|
199
386
|
} catch {
|
|
200
387
|
return null;
|
|
201
388
|
}
|
|
202
389
|
}
|
|
203
390
|
|
|
391
|
+
function resolveFrameLine(frame, lines) {
|
|
392
|
+
let lineno = frame.lineno;
|
|
393
|
+
if (!lines?.length || !lineno) return lineno;
|
|
394
|
+
|
|
395
|
+
if (lineno > lines.length) {
|
|
396
|
+
const inferredLine = inferLineFromErrorMessage(frame, lines);
|
|
397
|
+
if (inferredLine) {
|
|
398
|
+
lineno = inferredLine;
|
|
399
|
+
frame.lineno = inferredLine;
|
|
400
|
+
const identifier = getUndefinedIdentifier(frame.error_message);
|
|
401
|
+
if (identifier) {
|
|
402
|
+
const col = lines[inferredLine - 1].indexOf(identifier);
|
|
403
|
+
if (col >= 0) frame.colno = col + 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return lineno;
|
|
409
|
+
}
|
|
410
|
+
|
|
204
411
|
async function enrichFrameWithNodeSource(frame) {
|
|
205
412
|
if (!frame.in_app || !frame.lineno) return;
|
|
206
413
|
const lines = await readNodeSourceLines(frame.raw_abs_path || frame.abs_path);
|
|
207
|
-
if (lines)
|
|
414
|
+
if (!lines) return;
|
|
415
|
+
|
|
416
|
+
const lineno = resolveFrameLine(frame, lines);
|
|
417
|
+
applySourceContext(frame, lines, lineno);
|
|
208
418
|
}
|
|
209
419
|
|
|
210
420
|
function getBrowserSourceFetchCandidates(absPath, rawAbsPath) {
|
|
@@ -475,13 +685,21 @@ async function enrichFrameWithBrowserSource(frame) {
|
|
|
475
685
|
await enrichFrameWithSourceMap(frame);
|
|
476
686
|
}
|
|
477
687
|
|
|
478
|
-
export async function enrichStacktraceAsync(stacktrace) {
|
|
688
|
+
export async function enrichStacktraceAsync(stacktrace, errorMessage = null) {
|
|
479
689
|
if (!stacktrace?.frames?.length) return stacktrace;
|
|
480
690
|
|
|
481
691
|
const inAppFrames = stacktrace.frames.filter((f) => f.in_app);
|
|
482
692
|
// Enrich the error frame first (last in-app frame), then nearby frames.
|
|
483
693
|
const targets = [...inAppFrames.slice(-5)].reverse();
|
|
484
694
|
|
|
695
|
+
if (errorMessage) {
|
|
696
|
+
for (const frame of targets) {
|
|
697
|
+
if (!frame.error_message) {
|
|
698
|
+
frame.error_message = errorMessage;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
485
703
|
if (isNode) {
|
|
486
704
|
await Promise.all(targets.map((frame) => enrichFrameWithNodeSource(frame)));
|
|
487
705
|
} else if (isBrowser) {
|
package/src/tracing.js
CHANGED