@watchforge/browser 0.1.5 → 0.1.7
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/README.md +36 -3
- package/bin/watchforge.js +304 -17
- package/package.json +1 -1
- package/src/stacktrace.js +121 -36
package/README.md
CHANGED
|
@@ -7,8 +7,9 @@ Browser, Next.js, React, Node.js, and Express SDK for WatchForge. **One call to
|
|
|
7
7
|
| Runtime / Framework | Import | What it captures |
|
|
8
8
|
| --- | --- | --- |
|
|
9
9
|
| Browser JavaScript | `@watchforge/browser` | Uncaught errors, unhandled promise rejections, breadcrumbs, browser/device/page/performance context |
|
|
10
|
-
| React
|
|
11
|
-
|
|
|
10
|
+
| React / Vite / Vue / Angular | `@watchforge/browser` | Browser errors, unhandled promise rejections, breadcrumbs, replay, browser/device/page/performance context |
|
|
11
|
+
| React | `@watchforge/browser/react` | Optional React `ErrorBoundary` component stack context |
|
|
12
|
+
| Next.js App Router | `@watchforge/browser/next` + `@watchforge/browser/next/server` | Client-side errors, replay, global app render errors, Node runtime unhandled errors, route handler helper |
|
|
12
13
|
| Node.js | `@watchforge/browser` or `@watchforge/browser/node` | `uncaughtException`, `unhandledRejection`, Node runtime/server context |
|
|
13
14
|
| Express.js | `@watchforge/browser/express` | Express request errors, request URL/method/headers/body/query, user/IP, route, duration |
|
|
14
15
|
|
|
@@ -47,7 +48,39 @@ npx @watchforge/browser -i nextjs \
|
|
|
47
48
|
--replays-on-error 1
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
The wizard installs `@watchforge/browser
|
|
51
|
+
The wizard installs `@watchforge/browser` and writes the files needed for a full Next.js setup:
|
|
52
|
+
|
|
53
|
+
- `watchforge.config.ts` or `watchforge.config.js`
|
|
54
|
+
- `app/**/watchforge-init.tsx` / `.jsx` for browser errors and session replay
|
|
55
|
+
- `app/**/global-error.tsx` / `.jsx` for Next.js app render errors surfaced through Next's error boundary
|
|
56
|
+
- `src/instrumentation.ts` / `.js` or `instrumentation.ts` / `.js` for server runtime registration
|
|
57
|
+
- patches `app/layout.tsx` / `.jsx` or `pages/_app.tsx` / `.jsx`
|
|
58
|
+
|
|
59
|
+
For route handlers, wrap handlers with `withWatchForgeRouteHandler` from `@watchforge/browser/next/server`.
|
|
60
|
+
|
|
61
|
+
## One-line setup for other JavaScript projects
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# React
|
|
65
|
+
npx @watchforge/browser -i react --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
|
|
66
|
+
|
|
67
|
+
# Vite
|
|
68
|
+
npx @watchforge/browser -i vite --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
|
|
69
|
+
|
|
70
|
+
# Vue
|
|
71
|
+
npx @watchforge/browser -i vue --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
|
|
72
|
+
|
|
73
|
+
# Angular
|
|
74
|
+
npx @watchforge/browser -i angular --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
|
|
75
|
+
|
|
76
|
+
# Express
|
|
77
|
+
npx @watchforge/browser -i express --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID"
|
|
78
|
+
|
|
79
|
+
# Node.js
|
|
80
|
+
npx @watchforge/browser -i node --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For React/Vite/Vue/Angular, the wizard creates a browser init module and imports it from the detected entry file. For Express and Node.js, it creates a server setup file and prints the one or two lines to add to your server entry because middleware placement depends on each app.
|
|
51
84
|
|
|
52
85
|
## Framework Imports
|
|
53
86
|
|
package/bin/watchforge.js
CHANGED
|
@@ -10,10 +10,15 @@ WatchForge setup wizard
|
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
12
12
|
npx @watchforge/browser -i nextjs --dsn <dsn> [options]
|
|
13
|
-
npx @watchforge/browser
|
|
13
|
+
npx @watchforge/browser -i react --dsn <dsn> [options]
|
|
14
|
+
npx @watchforge/browser -i vite --dsn <dsn> [options]
|
|
15
|
+
npx @watchforge/browser -i vue --dsn <dsn> [options]
|
|
16
|
+
npx @watchforge/browser -i angular --dsn <dsn> [options]
|
|
17
|
+
npx @watchforge/browser -i express --dsn <dsn> [options]
|
|
18
|
+
npx @watchforge/browser -i node --dsn <dsn> [options]
|
|
14
19
|
|
|
15
20
|
Options:
|
|
16
|
-
-i, --integration <name> Framework integration
|
|
21
|
+
-i, --integration <name> Framework integration: nextjs, react, vite, vue, angular, express, node
|
|
17
22
|
--dsn <dsn> WatchForge DSN
|
|
18
23
|
--app-env <env> App environment (default: production)
|
|
19
24
|
--debug Enable SDK debug logging
|
|
@@ -140,8 +145,58 @@ function writeIfChanged(filePath, content) {
|
|
|
140
145
|
return true;
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
function toImportPath(fromDir, targetPath) {
|
|
149
|
+
let importPath = path.relative(fromDir, targetPath).replace(/\\/g, "/");
|
|
150
|
+
importPath = importPath.replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
151
|
+
if (!importPath.startsWith(".")) {
|
|
152
|
+
importPath = `./${importPath}`;
|
|
153
|
+
}
|
|
154
|
+
return importPath;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function toNodeImportPath(fromDir, targetPath) {
|
|
158
|
+
let importPath = path.relative(fromDir, targetPath).replace(/\\/g, "/");
|
|
159
|
+
importPath = importPath.replace(/\.(ts|tsx)$/, "");
|
|
160
|
+
if (!importPath.startsWith(".")) {
|
|
161
|
+
importPath = `./${importPath}`;
|
|
162
|
+
}
|
|
163
|
+
return importPath;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function projectUsesTypeScript(cwd) {
|
|
167
|
+
if (fileExists(path.join(cwd, "tsconfig.json"))) return true;
|
|
168
|
+
|
|
169
|
+
const srcDir = path.join(cwd, "src");
|
|
170
|
+
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return false;
|
|
171
|
+
|
|
172
|
+
const stack = [srcDir];
|
|
173
|
+
while (stack.length > 0) {
|
|
174
|
+
const dir = stack.pop();
|
|
175
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
176
|
+
const entryPath = path.join(dir, entry.name);
|
|
177
|
+
if (entry.isDirectory()) {
|
|
178
|
+
if (entry.name !== "node_modules" && entry.name !== ".next") {
|
|
179
|
+
stack.push(entryPath);
|
|
180
|
+
}
|
|
181
|
+
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getConfigPath(cwd) {
|
|
191
|
+
const tsConfig = path.join(cwd, "watchforge.config.ts");
|
|
192
|
+
const jsConfig = path.join(cwd, "watchforge.config.js");
|
|
193
|
+
if (fileExists(tsConfig)) return tsConfig;
|
|
194
|
+
if (fileExists(jsConfig)) return jsConfig;
|
|
195
|
+
return projectUsesTypeScript(cwd) ? tsConfig : jsConfig;
|
|
196
|
+
}
|
|
197
|
+
|
|
143
198
|
function createConfig(cwd, args) {
|
|
144
|
-
const configPath =
|
|
199
|
+
const configPath = getConfigPath(cwd);
|
|
145
200
|
const content = `export const watchforgeConfig = {
|
|
146
201
|
dsn: ${JSON.stringify(args.dsn)},
|
|
147
202
|
app_env: ${JSON.stringify(args.appEnv)},
|
|
@@ -153,6 +208,7 @@ function createConfig(cwd, args) {
|
|
|
153
208
|
`;
|
|
154
209
|
writeIfChanged(configPath, content);
|
|
155
210
|
log(`wrote ${path.relative(cwd, configPath)}`);
|
|
211
|
+
return configPath;
|
|
156
212
|
}
|
|
157
213
|
|
|
158
214
|
function installPackage(cwd, skipInstall) {
|
|
@@ -179,13 +235,12 @@ function installPackage(cwd, skipInstall) {
|
|
|
179
235
|
|
|
180
236
|
function patchAppRouter(cwd, layoutPath) {
|
|
181
237
|
const appDir = path.dirname(layoutPath);
|
|
182
|
-
const configPath =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const initPath = path.join(appDir, "watchforge-init.tsx");
|
|
238
|
+
const configPath = getConfigPath(cwd);
|
|
239
|
+
const configImport = toImportPath(appDir, configPath);
|
|
240
|
+
const initPath = path.join(
|
|
241
|
+
appDir,
|
|
242
|
+
projectUsesTypeScript(cwd) ? "watchforge-init.tsx" : "watchforge-init.jsx"
|
|
243
|
+
);
|
|
189
244
|
|
|
190
245
|
const initContent = `"use client";
|
|
191
246
|
|
|
@@ -213,10 +268,102 @@ export default function WatchForgeInit() {
|
|
|
213
268
|
log(`patched ${path.relative(cwd, layoutPath)}`);
|
|
214
269
|
}
|
|
215
270
|
|
|
271
|
+
function createNextGlobalError(cwd, layoutPath) {
|
|
272
|
+
const appDir = path.dirname(layoutPath);
|
|
273
|
+
const usesTs = projectUsesTypeScript(cwd);
|
|
274
|
+
const globalErrorPath = path.join(appDir, usesTs ? "global-error.tsx" : "global-error.jsx");
|
|
275
|
+
|
|
276
|
+
if (fileExists(globalErrorPath)) {
|
|
277
|
+
const content = fs.readFileSync(globalErrorPath, "utf8");
|
|
278
|
+
if (content.includes("@watchforge/browser") || content.includes("captureException")) {
|
|
279
|
+
log(`${path.relative(cwd, globalErrorPath)} already contains WatchForge setup`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
log(`skipped ${path.relative(cwd, globalErrorPath)} because it already exists`);
|
|
283
|
+
log("add captureException(error) there manually to report Next.js render errors");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const propsSignature = usesTs
|
|
288
|
+
? `{\n error,\n}: {\n error: Error & { digest?: string };\n}`
|
|
289
|
+
: `{ error }`;
|
|
290
|
+
|
|
291
|
+
const content = `"use client";
|
|
292
|
+
|
|
293
|
+
import { useEffect } from "react";
|
|
294
|
+
import { captureException } from "@watchforge/browser";
|
|
295
|
+
|
|
296
|
+
export default function GlobalError(${propsSignature}) {
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
void captureException(error, {
|
|
299
|
+
tags: {
|
|
300
|
+
framework: "nextjs",
|
|
301
|
+
runtime: "error-boundary",
|
|
302
|
+
},
|
|
303
|
+
extra: {
|
|
304
|
+
digest: error.digest,
|
|
305
|
+
},
|
|
306
|
+
contexts: {
|
|
307
|
+
nextjs: {
|
|
308
|
+
error_boundary: "global-error",
|
|
309
|
+
digest: error.digest || null,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}, [error]);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<html>
|
|
317
|
+
<body>
|
|
318
|
+
<h2>Something went wrong.</h2>
|
|
319
|
+
</body>
|
|
320
|
+
</html>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
`;
|
|
324
|
+
|
|
325
|
+
writeIfChanged(globalErrorPath, content);
|
|
326
|
+
log(`wrote ${path.relative(cwd, globalErrorPath)}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function createNextInstrumentation(cwd, layoutPath) {
|
|
330
|
+
const appRoot = path.dirname(layoutPath).includes(`${path.sep}src${path.sep}app`)
|
|
331
|
+
? path.join(cwd, "src")
|
|
332
|
+
: cwd;
|
|
333
|
+
const instrumentationPath = path.join(
|
|
334
|
+
appRoot,
|
|
335
|
+
projectUsesTypeScript(cwd) ? "instrumentation.ts" : "instrumentation.js"
|
|
336
|
+
);
|
|
337
|
+
const configImport = toImportPath(path.dirname(instrumentationPath), getConfigPath(cwd));
|
|
338
|
+
|
|
339
|
+
if (fileExists(instrumentationPath)) {
|
|
340
|
+
const content = fs.readFileSync(instrumentationPath, "utf8");
|
|
341
|
+
if (content.includes("@watchforge/browser/next/server")) {
|
|
342
|
+
log(`${path.relative(cwd, instrumentationPath)} already contains WatchForge setup`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
log(`skipped ${path.relative(cwd, instrumentationPath)} because it already exists`);
|
|
346
|
+
log("add registerWatchForge(watchforgeConfig) there manually to report Next.js server runtime errors");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const content = `import { register as registerWatchForge } from "@watchforge/browser/next/server";
|
|
351
|
+
import { watchforgeConfig } from "${configImport}";
|
|
352
|
+
|
|
353
|
+
export async function register() {
|
|
354
|
+
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
355
|
+
registerWatchForge(watchforgeConfig);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
`;
|
|
359
|
+
|
|
360
|
+
writeIfChanged(instrumentationPath, content);
|
|
361
|
+
log(`wrote ${path.relative(cwd, instrumentationPath)}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
216
364
|
function patchPagesRouter(cwd, appPath) {
|
|
217
365
|
const pagesDir = path.dirname(appPath);
|
|
218
|
-
const
|
|
219
|
-
const configImport = isSrcPages ? "../../watchforge.config" : "../watchforge.config";
|
|
366
|
+
const configImport = toImportPath(pagesDir, getConfigPath(cwd));
|
|
220
367
|
const content = fs.readFileSync(appPath, "utf8");
|
|
221
368
|
|
|
222
369
|
if (content.includes("register(watchforgeConfig)")) {
|
|
@@ -237,6 +384,133 @@ ${content.replace(
|
|
|
237
384
|
log(`patched ${path.relative(cwd, appPath)}`);
|
|
238
385
|
}
|
|
239
386
|
|
|
387
|
+
function findFirstFile(cwd, candidates) {
|
|
388
|
+
return candidates.map((candidate) => path.join(cwd, candidate)).find(fileExists) || null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function prependImportOnce(filePath, importLine) {
|
|
392
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
393
|
+
if (content.includes(importLine)) return false;
|
|
394
|
+
fs.writeFileSync(filePath, `${importLine}\n${content}`);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function createBrowserInit(cwd, initRelativePath = null) {
|
|
399
|
+
const defaultPath = projectUsesTypeScript(cwd)
|
|
400
|
+
? "src/watchforge-init.ts"
|
|
401
|
+
: "src/watchforge-init.js";
|
|
402
|
+
const initPath = path.join(cwd, initRelativePath || defaultPath);
|
|
403
|
+
const configImport = toImportPath(path.dirname(initPath), getConfigPath(cwd));
|
|
404
|
+
const content = `import { register } from "@watchforge/browser";
|
|
405
|
+
import { watchforgeConfig } from "${configImport}";
|
|
406
|
+
|
|
407
|
+
register(watchforgeConfig);
|
|
408
|
+
`;
|
|
409
|
+
|
|
410
|
+
writeIfChanged(initPath, content);
|
|
411
|
+
log(`wrote ${path.relative(cwd, initPath)}`);
|
|
412
|
+
return initPath;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function initBrowserFramework(args, integration) {
|
|
416
|
+
const cwd = process.cwd();
|
|
417
|
+
if (!fileExists(path.join(cwd, "package.json"))) {
|
|
418
|
+
fail(`run this command from the root of your ${integration} project`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
createConfig(cwd, args);
|
|
422
|
+
installPackage(cwd, args.skipInstall);
|
|
423
|
+
|
|
424
|
+
const entryPath = findFirstFile(cwd, [
|
|
425
|
+
"src/main.tsx",
|
|
426
|
+
"src/main.jsx",
|
|
427
|
+
"src/main.ts",
|
|
428
|
+
"src/main.js",
|
|
429
|
+
"src/index.tsx",
|
|
430
|
+
"src/index.jsx",
|
|
431
|
+
"src/index.ts",
|
|
432
|
+
"src/index.js",
|
|
433
|
+
"main.ts",
|
|
434
|
+
"main.js",
|
|
435
|
+
"index.ts",
|
|
436
|
+
"index.js",
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
const initPath = createBrowserInit(cwd);
|
|
440
|
+
if (!entryPath) {
|
|
441
|
+
log("could not find a browser entry file to patch");
|
|
442
|
+
log(`import "./${path.relative(cwd, initPath).replace(/\\/g, "/").replace(/\.(ts|js)$/, "")}" from your app entry`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const initImport = toImportPath(path.dirname(entryPath), initPath);
|
|
447
|
+
if (prependImportOnce(entryPath, `import "${initImport}";`)) {
|
|
448
|
+
log(`patched ${path.relative(cwd, entryPath)}`);
|
|
449
|
+
} else {
|
|
450
|
+
log(`${path.relative(cwd, entryPath)} already imports WatchForge`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function initExpress(args) {
|
|
455
|
+
const cwd = process.cwd();
|
|
456
|
+
if (!fileExists(path.join(cwd, "package.json"))) {
|
|
457
|
+
fail("run this command from the root of your Express project");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
createConfig(cwd, args);
|
|
461
|
+
installPackage(cwd, args.skipInstall);
|
|
462
|
+
|
|
463
|
+
const setupPath = path.join(
|
|
464
|
+
cwd,
|
|
465
|
+
projectUsesTypeScript(cwd) ? "watchforge-express.ts" : "watchforge-express.js"
|
|
466
|
+
);
|
|
467
|
+
const configImport = toNodeImportPath(path.dirname(setupPath), getConfigPath(cwd));
|
|
468
|
+
const content = `import { register } from "@watchforge/browser/node";
|
|
469
|
+
import {
|
|
470
|
+
expressMiddleware,
|
|
471
|
+
expressRequestMiddleware,
|
|
472
|
+
} from "@watchforge/browser/express";
|
|
473
|
+
import { watchforgeConfig } from "${configImport}";
|
|
474
|
+
|
|
475
|
+
register(watchforgeConfig);
|
|
476
|
+
|
|
477
|
+
export { expressMiddleware, expressRequestMiddleware };
|
|
478
|
+
`;
|
|
479
|
+
|
|
480
|
+
writeIfChanged(setupPath, content);
|
|
481
|
+
log(`wrote ${path.relative(cwd, setupPath)}`);
|
|
482
|
+
log("add this near the top of your Express app:");
|
|
483
|
+
log(' import { expressRequestMiddleware, expressMiddleware } from "./watchforge-express";');
|
|
484
|
+
log(" app.use(expressRequestMiddleware());");
|
|
485
|
+
log("and add this after your routes:");
|
|
486
|
+
log(" app.use(expressMiddleware());");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function initNode(args) {
|
|
490
|
+
const cwd = process.cwd();
|
|
491
|
+
if (!fileExists(path.join(cwd, "package.json"))) {
|
|
492
|
+
fail("run this command from the root of your Node.js project");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
createConfig(cwd, args);
|
|
496
|
+
installPackage(cwd, args.skipInstall);
|
|
497
|
+
|
|
498
|
+
const setupPath = path.join(
|
|
499
|
+
cwd,
|
|
500
|
+
projectUsesTypeScript(cwd) ? "watchforge-node.ts" : "watchforge-node.js"
|
|
501
|
+
);
|
|
502
|
+
const configImport = toNodeImportPath(path.dirname(setupPath), getConfigPath(cwd));
|
|
503
|
+
const content = `import { register } from "@watchforge/browser/node";
|
|
504
|
+
import { watchforgeConfig } from "${configImport}";
|
|
505
|
+
|
|
506
|
+
register(watchforgeConfig);
|
|
507
|
+
`;
|
|
508
|
+
|
|
509
|
+
writeIfChanged(setupPath, content);
|
|
510
|
+
log(`wrote ${path.relative(cwd, setupPath)}`);
|
|
511
|
+
log('import "./watchforge-node"; as early as possible in your server entry file');
|
|
512
|
+
}
|
|
513
|
+
|
|
240
514
|
function initNextjs(args) {
|
|
241
515
|
const cwd = process.cwd();
|
|
242
516
|
if (!fileExists(path.join(cwd, "package.json"))) {
|
|
@@ -249,6 +523,8 @@ function initNextjs(args) {
|
|
|
249
523
|
const layoutPath = findNextLayout(cwd);
|
|
250
524
|
if (layoutPath) {
|
|
251
525
|
patchAppRouter(cwd, layoutPath);
|
|
526
|
+
createNextGlobalError(cwd, layoutPath);
|
|
527
|
+
createNextInstrumentation(cwd, layoutPath);
|
|
252
528
|
return;
|
|
253
529
|
}
|
|
254
530
|
|
|
@@ -272,13 +548,24 @@ if (!args.integration) {
|
|
|
272
548
|
fail("missing integration. Use: -i nextjs");
|
|
273
549
|
}
|
|
274
550
|
|
|
275
|
-
if (args.integration !== "nextjs") {
|
|
276
|
-
fail(`unsupported integration "${args.integration}". Currently supported: nextjs`);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
551
|
if (!args.dsn) {
|
|
280
552
|
fail("missing --dsn");
|
|
281
553
|
}
|
|
282
554
|
|
|
283
|
-
|
|
555
|
+
const integration = args.integration.toLowerCase();
|
|
556
|
+
|
|
557
|
+
if (integration === "nextjs" || integration === "next") {
|
|
558
|
+
initNextjs(args);
|
|
559
|
+
} else if (["react", "vite", "vue", "angular", "browser"].includes(integration)) {
|
|
560
|
+
initBrowserFramework(args, integration);
|
|
561
|
+
} else if (integration === "express") {
|
|
562
|
+
initExpress(args);
|
|
563
|
+
} else if (integration === "node" || integration === "nodejs") {
|
|
564
|
+
initNode(args);
|
|
565
|
+
} else {
|
|
566
|
+
fail(
|
|
567
|
+
`unsupported integration "${args.integration}". Supported: nextjs, react, vite, vue, angular, express, node`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
284
571
|
log("setup complete");
|
package/package.json
CHANGED
package/src/stacktrace.js
CHANGED
|
@@ -68,16 +68,21 @@ export function buildStacktraceFromError(error) {
|
|
|
68
68
|
if (!parsed) continue;
|
|
69
69
|
|
|
70
70
|
const { function: functionName, file, lineno, colno } = parsed;
|
|
71
|
-
const
|
|
72
|
-
const
|
|
71
|
+
const normalizedPath = normalizeSourcePath(file) || file;
|
|
72
|
+
const filename =
|
|
73
|
+
normalizedPath.split("/").pop()?.split("?")[0] ||
|
|
74
|
+
file.split("/").pop()?.split("?")[0] ||
|
|
75
|
+
file;
|
|
76
|
+
const inApp = !isLibraryPath(file) && !isLibraryPath(normalizedPath);
|
|
73
77
|
|
|
74
78
|
frames.push({
|
|
75
79
|
filename,
|
|
76
|
-
abs_path:
|
|
80
|
+
abs_path: normalizedPath,
|
|
81
|
+
raw_abs_path: file,
|
|
77
82
|
lineno,
|
|
78
83
|
colno,
|
|
79
84
|
function: functionName,
|
|
80
|
-
module:
|
|
85
|
+
module: normalizedPath,
|
|
81
86
|
context_line: null,
|
|
82
87
|
pre_context: [],
|
|
83
88
|
post_context: [],
|
|
@@ -198,44 +203,73 @@ async function readNodeSourceLines(absPath) {
|
|
|
198
203
|
|
|
199
204
|
async function enrichFrameWithNodeSource(frame) {
|
|
200
205
|
if (!frame.in_app || !frame.lineno) return;
|
|
201
|
-
const lines = await readNodeSourceLines(frame.abs_path);
|
|
206
|
+
const lines = await readNodeSourceLines(frame.raw_abs_path || frame.abs_path);
|
|
202
207
|
if (lines) applySourceContext(frame, lines, frame.lineno);
|
|
203
208
|
}
|
|
204
209
|
|
|
205
|
-
|
|
206
|
-
|
|
210
|
+
function getBrowserSourceFetchCandidates(absPath, rawAbsPath) {
|
|
211
|
+
const candidates = new Set();
|
|
212
|
+
const paths = [absPath, rawAbsPath].filter(Boolean);
|
|
207
213
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
214
|
+
for (const path of paths) {
|
|
215
|
+
const normalized = normalizeSourcePath(path);
|
|
216
|
+
if (!normalized) continue;
|
|
217
|
+
|
|
218
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
219
|
+
candidates.add(path);
|
|
212
220
|
}
|
|
213
221
|
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
}
|
|
222
|
+
if (normalized.startsWith("/")) {
|
|
223
|
+
candidates.add(`${window.location.origin}${normalized}`);
|
|
224
|
+
} else {
|
|
225
|
+
candidates.add(`${window.location.origin}/${normalized}`);
|
|
226
|
+
candidates.add(new URL(normalized, window.location.href).href);
|
|
220
227
|
}
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
229
|
+
if (path.startsWith("webpack-internal://")) {
|
|
230
|
+
const stripped = path
|
|
231
|
+
.replace(/^webpack-internal:\/\/\/?/, "/")
|
|
232
|
+
.replace(/^webpack-internal:\/\//, "/");
|
|
233
|
+
candidates.add(`${window.location.origin}${stripped}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
224
236
|
|
|
225
|
-
|
|
226
|
-
|
|
237
|
+
return [...candidates];
|
|
238
|
+
}
|
|
227
239
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
240
|
+
async function fetchBrowserSourceLines(absPath, rawAbsPath) {
|
|
241
|
+
if (!isBrowser || !absPath) return null;
|
|
232
242
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
const candidates = getBrowserSourceFetchCandidates(absPath, rawAbsPath);
|
|
244
|
+
|
|
245
|
+
for (const candidate of candidates) {
|
|
246
|
+
try {
|
|
247
|
+
const target = new URL(candidate, window.location.href);
|
|
248
|
+
if (target.origin !== window.location.origin) continue;
|
|
249
|
+
|
|
250
|
+
const response = await fetch(target.href, { credentials: "same-origin" });
|
|
251
|
+
if (!response.ok) continue;
|
|
252
|
+
|
|
253
|
+
const contentType = response.headers.get("content-type") || "";
|
|
254
|
+
if (
|
|
255
|
+
!contentType.includes("javascript") &&
|
|
256
|
+
!contentType.includes("text") &&
|
|
257
|
+
!contentType.includes("typescript") &&
|
|
258
|
+
!contentType.includes("json")
|
|
259
|
+
) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
if (text.length > MAX_SOURCE_FILE_BYTES) continue;
|
|
265
|
+
if (!text.includes("\n") && !text.includes(";") && text.length > 500) continue;
|
|
266
|
+
return text.split("\n");
|
|
267
|
+
} catch {
|
|
268
|
+
// try next candidate
|
|
269
|
+
}
|
|
238
270
|
}
|
|
271
|
+
|
|
272
|
+
return null;
|
|
239
273
|
}
|
|
240
274
|
|
|
241
275
|
async function getSourceMapConsumer() {
|
|
@@ -294,9 +328,55 @@ async function fetchBrowserSourceMap(absPath) {
|
|
|
294
328
|
}
|
|
295
329
|
}
|
|
296
330
|
|
|
331
|
+
async function findSourceMapForFrame(frame) {
|
|
332
|
+
const wanted = normalizeSourcePath(frame.abs_path || frame.module);
|
|
333
|
+
const scriptUrls = [];
|
|
334
|
+
|
|
335
|
+
if (frame.raw_abs_path?.startsWith("http://") || frame.raw_abs_path?.startsWith("https://")) {
|
|
336
|
+
scriptUrls.push(frame.raw_abs_path);
|
|
337
|
+
}
|
|
338
|
+
if (frame.abs_path?.startsWith("http://") || frame.abs_path?.startsWith("https://")) {
|
|
339
|
+
scriptUrls.push(frame.abs_path);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (isBrowser) {
|
|
343
|
+
for (const script of document.querySelectorAll("script[src]")) {
|
|
344
|
+
const src = script.getAttribute("src");
|
|
345
|
+
if (!src) continue;
|
|
346
|
+
try {
|
|
347
|
+
const url = new URL(src, window.location.href);
|
|
348
|
+
if (url.origin === window.location.origin) {
|
|
349
|
+
scriptUrls.push(url.href);
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// ignore invalid script src
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const seen = new Set();
|
|
358
|
+
for (const scriptUrl of scriptUrls) {
|
|
359
|
+
if (!scriptUrl || seen.has(scriptUrl)) continue;
|
|
360
|
+
seen.add(scriptUrl);
|
|
361
|
+
|
|
362
|
+
const sourceMap = await fetchBrowserSourceMap(scriptUrl);
|
|
363
|
+
if (!sourceMap) continue;
|
|
364
|
+
|
|
365
|
+
const sources = Array.isArray(sourceMap.sources) ? sourceMap.sources : [];
|
|
366
|
+
const hasMatch = sources.some((source) => sourceMatchesFrame(source, wanted));
|
|
367
|
+
if (hasMatch || !wanted) {
|
|
368
|
+
return { sourceMap, scriptUrl };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
297
375
|
async function enrichFrameWithSourceMap(frame) {
|
|
298
|
-
const
|
|
299
|
-
if (!
|
|
376
|
+
const resolved = await findSourceMapForFrame(frame);
|
|
377
|
+
if (!resolved) return false;
|
|
378
|
+
|
|
379
|
+
const { sourceMap } = resolved;
|
|
300
380
|
|
|
301
381
|
try {
|
|
302
382
|
const SourceMapConsumer = await getSourceMapConsumer();
|
|
@@ -358,7 +438,9 @@ function getSourceContentFromWebpackFrame(frame) {
|
|
|
358
438
|
value.some((item) => Array.isArray(item) && item.length >= 2)
|
|
359
439
|
) || [];
|
|
360
440
|
|
|
361
|
-
const framePath = normalizeSourcePath(
|
|
441
|
+
const framePath = normalizeSourcePath(
|
|
442
|
+
frame.abs_path || frame.module || frame.raw_abs_path
|
|
443
|
+
);
|
|
362
444
|
if (!framePath || !Array.isArray(webpackChunks)) return null;
|
|
363
445
|
|
|
364
446
|
for (const chunk of webpackChunks) {
|
|
@@ -384,7 +466,7 @@ async function enrichFrameWithBrowserSource(frame) {
|
|
|
384
466
|
if (frame.context_line) return;
|
|
385
467
|
}
|
|
386
468
|
|
|
387
|
-
const lines = await fetchBrowserSourceLines(frame.abs_path);
|
|
469
|
+
const lines = await fetchBrowserSourceLines(frame.abs_path, frame.raw_abs_path);
|
|
388
470
|
if (lines) {
|
|
389
471
|
applySourceContext(frame, lines, frame.lineno);
|
|
390
472
|
if (frame.context_line) return;
|
|
@@ -397,12 +479,15 @@ export async function enrichStacktraceAsync(stacktrace) {
|
|
|
397
479
|
if (!stacktrace?.frames?.length) return stacktrace;
|
|
398
480
|
|
|
399
481
|
const inAppFrames = stacktrace.frames.filter((f) => f.in_app);
|
|
400
|
-
|
|
482
|
+
// Enrich the error frame first (last in-app frame), then nearby frames.
|
|
483
|
+
const targets = [...inAppFrames.slice(-5)].reverse();
|
|
401
484
|
|
|
402
485
|
if (isNode) {
|
|
403
486
|
await Promise.all(targets.map((frame) => enrichFrameWithNodeSource(frame)));
|
|
404
487
|
} else if (isBrowser) {
|
|
405
|
-
|
|
488
|
+
for (const frame of targets) {
|
|
489
|
+
await enrichFrameWithBrowserSource(frame);
|
|
490
|
+
}
|
|
406
491
|
}
|
|
407
492
|
|
|
408
493
|
return stacktrace;
|