@backtrack-js/browser 0.4.1 → 0.5.0
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 +106 -62
- package/bin/symbolize.cjs +205 -62
- package/dist/index.cjs +25 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1398 -1349
- package/dist/index.js.map +1 -1
- package/dist/types/timeline.d.ts +3 -2
- package/dist/types/timeline.d.ts.map +1 -1
- package/dist/utils/markdown.d.ts.map +1 -1
- package/dist/validation/validate.d.ts.map +1 -1
- package/dist/viewer/assets/{index-C5T1st3y.js → index-CRrpVNC0.js} +34 -33
- package/dist/viewer/index.html +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,16 +5,19 @@ A flight recorder for your web app. Replay the exact moments before a bug, priva
|
|
|
5
5
|
[](https://www.npmjs.com/package/@backtrack-js/browser)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
+
<!-- TODO: add a GIF or screenshot of the Incident Viewer here (this is the highest-impact change) -->
|
|
9
|
+
<!--  -->
|
|
10
|
+
|
|
8
11
|
Your users hit a bug you can't reproduce. Error trackers tell you what broke, but not what the user did before it did.
|
|
9
12
|
|
|
10
13
|
Backtrack keeps the last few minutes of DOM changes, clicks, network calls, console logs and errors in a rolling local buffer. When something breaks (or the user asks for help), it freezes that buffer into a replayable incident file. Open it in the built-in viewer and press play.
|
|
11
14
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
15
|
+
- **Private by default.** Passwords, card numbers, CPFs and form inputs are masked. Request and response bodies are off unless you turn them on. Nothing leaves the browser unless you export an incident or configure a transport.
|
|
16
|
+
- **Light on the page.** Recording runs into a ring buffer in IndexedDB with automatic retention, so old events are dropped instead of piling up.
|
|
17
|
+
- **Framework-agnostic.** Pure TypeScript core, no React or other framework in your client bundle. ESM and CommonJS builds.
|
|
18
|
+
- **Replay offline.** An interactive viewer with DOM playback, a synchronized timeline, and console and network panels. No account, no server.
|
|
19
|
+
- **Readable production stack traces.** `backtrack symbolize` maps minified frames like `app.min.js:1:48213` back to your original TypeScript, with the surrounding source lines.
|
|
20
|
+
- **Send it where you want.** `transport` and `beforeSend` let you forward incidents to your own backend or observability stack.
|
|
18
21
|
|
|
19
22
|
---
|
|
20
23
|
|
|
@@ -52,28 +55,15 @@ pnpm add @backtrack-js/browser
|
|
|
52
55
|
```typescript
|
|
53
56
|
import { Backtrack } from '@backtrack-js/browser';
|
|
54
57
|
|
|
55
|
-
// 1. Initialize Backtrack
|
|
56
58
|
const recorder = new Backtrack({
|
|
57
|
-
bufferMinutes: 5,
|
|
58
|
-
afterErrorSeconds: 15,
|
|
59
|
-
maxStorageMb: 50, // target storage quota for rolling buffer
|
|
60
|
-
captureHttpStatus: [500, 502, 503, 504], // auto-trigger on server errors
|
|
61
|
-
network: {
|
|
62
|
-
capturePayloads: false, // bodies are disabled by default for privacy
|
|
63
|
-
maxPayloadSize: 64 * 1024
|
|
64
|
-
},
|
|
65
|
-
privacy: {
|
|
66
|
-
maskAllInputs: true, // mask all form inputs by default
|
|
67
|
-
blockMedia: true // replace images/media with placeholders
|
|
68
|
-
}
|
|
59
|
+
bufferMinutes: 5, // keep the last 5 minutes in the rolling buffer
|
|
60
|
+
afterErrorSeconds: 15, // keep recording 15s after an error
|
|
69
61
|
});
|
|
70
62
|
|
|
71
|
-
// 2. Start recording
|
|
72
63
|
await recorder.start();
|
|
73
64
|
|
|
74
|
-
//
|
|
65
|
+
// Optional: capture manually, e.g. from a "Report a problem" button
|
|
75
66
|
const incidentId = await recorder.capture('User reported payment bug', 30);
|
|
76
|
-
console.log('Incident captured:', incidentId);
|
|
77
67
|
```
|
|
78
68
|
|
|
79
69
|
Then inspect it:
|
|
@@ -86,6 +76,73 @@ Drag an exported incident `.json` into the viewer and hit play.
|
|
|
86
76
|
|
|
87
77
|
---
|
|
88
78
|
|
|
79
|
+
## Readable production stack traces (`backtrack symbolize`)
|
|
80
|
+
|
|
81
|
+
Production bundles are minified, so a stack frame like `app.3f9a1c.js:1:48213` tells you nothing. `backtrack symbolize` reads your source maps and rewrites the incident's errors with the original file, line, column and the surrounding source code.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx backtrack symbolize <incident> --sourcemaps <directory> [options]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Argument / option | Description |
|
|
88
|
+
| :--- | :--- |
|
|
89
|
+
| `<incident>` | Path to an exported incident: `.json`, `.ffr.json`, `.gz` or `.ffr.json.gz`. Gzip is detected automatically. |
|
|
90
|
+
| `--sourcemaps <directory>` | Directory containing your `.js.map`, `.mjs.map` and `.cjs.map` files. Searched recursively (e.g. `./dist`, `./build`). |
|
|
91
|
+
| `--output <file>` | Custom output path. Defaults to `<name>.symbolicated.ffr.json` next to the original. The output can never overwrite the input file. |
|
|
92
|
+
| `--release <id>` | Build or release identifier. Validates against the incident's recorded release (if present), warning on mismatch. |
|
|
93
|
+
| `--context-lines <number>` | Lines of original source shown before and after the failing line. Range 0–10, default 3. |
|
|
94
|
+
| `-h, --help` | Show help. |
|
|
95
|
+
|
|
96
|
+
### Example
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx backtrack symbolize ./incidents/checkout-bug.ffr.json --sourcemaps ./dist --context-lines 5
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
[Backtrack] Symbolication concluída.
|
|
104
|
+
|
|
105
|
+
Incidente: inc_1727500000_3f2a
|
|
106
|
+
Erros encontrados: 1
|
|
107
|
+
Frames da exceção resolvidos: 4
|
|
108
|
+
Frames da exceção não resolvidos: 0
|
|
109
|
+
Frames React resolvidos: 2
|
|
110
|
+
Frames React não resolvidos: 0
|
|
111
|
+
Saída: /projects/app/incidents/checkout-bug.symbolicated.ffr.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If some frames have no matching source map, the command reports a partial symbolication with warnings instead of failing.
|
|
115
|
+
|
|
116
|
+
Open the `.symbolicated.ffr.json` file in the viewer (`npx backtrack`) and the error tab shows the resolved stack trace, with the failing line highlighted:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
applyCoupon src/cart/coupon.ts:42:18
|
|
120
|
+
|
|
121
|
+
40 function applyCoupon(cart, coupon) {
|
|
122
|
+
41 // coupon discount might be missing from API
|
|
123
|
+
42 const off = coupon.discount.percent; <-- highlighted
|
|
124
|
+
43 return cart.total * (1 - off / 100);
|
|
125
|
+
44 }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### What it does (and doesn't do)
|
|
129
|
+
|
|
130
|
+
- **Validates safely:** Reads the incident (up to 50 MB, gzip supported) and validates its schema with a strict validator. The original file is never modified.
|
|
131
|
+
- **Multi-browser stack parsing:** Supports V8 (Chrome/Edge), Firefox, and Safari stack formats, stripping URL hashes/query strings and skipping native frames (`[native code]`).
|
|
132
|
+
- **Resolves both exception location and React tree:** Simboliza tanto o ponto onde a exceção foi lançada (`resolvedStack`) quanto a árvore de componentes React (`resolvedComponentStack`), mantendo-os estritamente separados.
|
|
133
|
+
- **Sourcemap validation:** Checks the sourcemap `file` attribute against the minified bundle to prevent cross-bundle misattribution.
|
|
134
|
+
- **Extracts source context:** Extracts the original code from `sourcesContent`, so your source maps must include it.
|
|
135
|
+
- **Doesn't leak local paths:** File paths are normalized (e.g. `C:\Users\alice\...` or `/home/bob/...` become paths starting at `src/`).
|
|
136
|
+
- **Atomic write:** Writes the result atomically (to a `.tmp` file, then renames) and stores it in `resolvedStack` and `resolvedComponentStack`.
|
|
137
|
+
|
|
138
|
+
### Limitations & Caveats
|
|
139
|
+
|
|
140
|
+
- **Frames without line/column:** Stack frames missing line or column numbers cannot be mapped to source locations.
|
|
141
|
+
- **Matching sourcemap release:** Source maps must correspond to the exact production build that generated the incident. Use `--release <id>` to ensure compatibility.
|
|
142
|
+
- **Privacy notice:** O artefato poderá incluir replay visual, URLs, logs, respostas de rede, anotações e trechos do código-fonte incorporados durante a symbolication.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
89
146
|
## Privacy & Data Masking
|
|
90
147
|
|
|
91
148
|
Backtrack is designed with strict security defaults:
|
|
@@ -119,7 +176,7 @@ Network requests and responses can be configured using `network` options:
|
|
|
119
176
|
```typescript
|
|
120
177
|
const recorder = new Backtrack({
|
|
121
178
|
network: {
|
|
122
|
-
capturePayloads: false,
|
|
179
|
+
capturePayloads: false, // Bodies are disabled by default for privacy
|
|
123
180
|
maxPayloadSize: 64 * 1024
|
|
124
181
|
}
|
|
125
182
|
});
|
|
@@ -155,15 +212,36 @@ const jsonBlob = new Blob([JSON.stringify(artifact, null, 2)], { type: 'applicat
|
|
|
155
212
|
```
|
|
156
213
|
|
|
157
214
|
### Exception Capture
|
|
158
|
-
|
|
215
|
+
|
|
216
|
+
#### React Error Boundary
|
|
217
|
+
Em Error Boundaries (ex: `componentDidCatch` ou `react-error-boundary`), forneça `source: 'react'` e a árvore de componentes `componentStack`:
|
|
159
218
|
|
|
160
219
|
```typescript
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
220
|
+
// React Error Boundary (captura renderização/lifecycle com árvore de componentes)
|
|
221
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
222
|
+
recorder.captureException(error, {
|
|
223
|
+
source: 'react',
|
|
224
|
+
componentStack: errorInfo.componentStack
|
|
225
|
+
});
|
|
226
|
+
}
|
|
165
227
|
```
|
|
166
228
|
|
|
229
|
+
#### Captura manual (try / catch)
|
|
230
|
+
Para exceções capturadas imperativamente em handlers de eventos, chamadas assíncronas ou blocos `try/catch`, use `source: 'manual'`:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// Captura imperativa sem árvore de componentes React
|
|
234
|
+
try {
|
|
235
|
+
processCheckout();
|
|
236
|
+
} catch (err) {
|
|
237
|
+
recorder.captureException(err, {
|
|
238
|
+
source: 'manual'
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
> **Atenção:** Use `source: 'react'` apenas quando o erro foi capturado por um Error Boundary com a árvore de renderização. O visualizador e o simbolizador tratam `source: 'react'` e `componentStack` como a árvore de componentes da interface, e a stack JavaScript comum como o local exato do disparo da exceção.
|
|
244
|
+
|
|
167
245
|
---
|
|
168
246
|
|
|
169
247
|
## Remote Transport & beforeSend
|
|
@@ -279,40 +357,6 @@ const recorder = new Backtrack({
|
|
|
279
357
|
|
|
280
358
|
---
|
|
281
359
|
|
|
282
|
-
## Source maps & Symbolication
|
|
283
|
-
|
|
284
|
-
In production, client-side bundles are minified, producing stack traces that are hard to decipher:
|
|
285
|
-
|
|
286
|
-
```text
|
|
287
|
-
at t (app.min.js:1:48213)
|
|
288
|
-
at onClick (app.min.js:1:51870)
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
Use the built-in CLI to symbolize your incident artifact using your local `.map` files:
|
|
292
|
-
|
|
293
|
-
```bash
|
|
294
|
-
npx backtrack symbolize <incident.json> --sourcemaps <path/to/sourcemaps>
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
Example:
|
|
298
|
-
|
|
299
|
-
```bash
|
|
300
|
-
npx backtrack symbolize incident-checkout.json --sourcemaps ./dist/assets
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
This resolves minified frames to your original TypeScript source files, line and column numbers, and embeds surrounding code context directly into the artifact for viewing in the web inspector:
|
|
304
|
-
|
|
305
|
-
```text
|
|
306
|
-
applyCoupon src/cart/coupon.ts:42:18
|
|
307
|
-
40 function applyCoupon(cart, coupon) {
|
|
308
|
-
41 // coupon discount might be missing from API
|
|
309
|
-
42 const off = coupon.discount.percent;
|
|
310
|
-
43 return cart.total * (1 - off / 100);
|
|
311
|
-
44 }
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
---
|
|
315
|
-
|
|
316
360
|
## Inspecting Incidents (Viewer)
|
|
317
361
|
|
|
318
362
|
Backtrack includes an offline visualizer with video replay, console logs, network inspection, and breadcrumbs.
|
package/bin/symbolize.cjs
CHANGED
|
@@ -7,9 +7,77 @@ const {
|
|
|
7
7
|
sourceContentFor
|
|
8
8
|
} = require('@jridgewell/trace-mapping');
|
|
9
9
|
|
|
10
|
-
// ponytail: parser inicial cobre stacks V8; adicionar Firefox/Safari quando fixtures reais exigirem.
|
|
11
10
|
const FRAME_WITH_FUNCTION = /^\s*at\s+(.+?)\s+\((.+):(\d+):(\d+)\)\s*$/;
|
|
11
|
+
const FRAME_WITHOUT_FUNCTION_PARENS = /^\s*at\s+\((.+):(\d+):(\d+)\)\s*$/;
|
|
12
12
|
const FRAME_WITHOUT_FUNCTION = /^\s*at\s+(.+):(\d+):(\d+)\s*$/;
|
|
13
|
+
const FRAME_FIREFOX_SAFARI = /^\s*(.*?)\s*@\s*(.+):(\d+):(\d+)\s*$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Analisa uma linha de stack trace (V8, Firefox ou Safari) e extrai
|
|
17
|
+
* nome da função, arquivo gerado, linha e coluna.
|
|
18
|
+
* @param {string} line
|
|
19
|
+
* @returns {{ functionName?: string, generatedFile: string, generatedLine: number, generatedColumn: number } | undefined}
|
|
20
|
+
*/
|
|
21
|
+
function parseStackFrame(line) {
|
|
22
|
+
if (!line || typeof line !== 'string') {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let functionName = undefined;
|
|
27
|
+
let generatedFile = undefined;
|
|
28
|
+
let lineStr = undefined;
|
|
29
|
+
let colStr = undefined;
|
|
30
|
+
|
|
31
|
+
const withFnMatch = line.match(FRAME_WITH_FUNCTION);
|
|
32
|
+
if (withFnMatch) {
|
|
33
|
+
functionName = withFnMatch[1].trim();
|
|
34
|
+
generatedFile = withFnMatch[2].trim();
|
|
35
|
+
lineStr = withFnMatch[3];
|
|
36
|
+
colStr = withFnMatch[4];
|
|
37
|
+
} else {
|
|
38
|
+
const withoutFnParensMatch = line.match(FRAME_WITHOUT_FUNCTION_PARENS);
|
|
39
|
+
if (withoutFnParensMatch) {
|
|
40
|
+
generatedFile = withoutFnParensMatch[1].trim();
|
|
41
|
+
lineStr = withoutFnParensMatch[2];
|
|
42
|
+
colStr = withoutFnParensMatch[3];
|
|
43
|
+
} else {
|
|
44
|
+
const withoutFnMatch = line.match(FRAME_WITHOUT_FUNCTION);
|
|
45
|
+
if (withoutFnMatch) {
|
|
46
|
+
generatedFile = withoutFnMatch[1].trim();
|
|
47
|
+
lineStr = withoutFnMatch[2];
|
|
48
|
+
colStr = withoutFnMatch[3];
|
|
49
|
+
} else {
|
|
50
|
+
const ffSafariMatch = line.match(FRAME_FIREFOX_SAFARI);
|
|
51
|
+
if (ffSafariMatch) {
|
|
52
|
+
const fn = ffSafariMatch[1].trim();
|
|
53
|
+
functionName = fn.length > 0 ? fn : undefined;
|
|
54
|
+
generatedFile = ffSafariMatch[2].trim();
|
|
55
|
+
lineStr = ffSafariMatch[3];
|
|
56
|
+
colStr = ffSafariMatch[4];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!generatedFile || !lineStr || !colStr) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const generatedLine = parseInt(lineStr, 10);
|
|
67
|
+
const generatedColumn = parseInt(colStr, 10);
|
|
68
|
+
|
|
69
|
+
if (!Number.isInteger(generatedLine) || generatedLine <= 0 ||
|
|
70
|
+
!Number.isInteger(generatedColumn) || generatedColumn <= 0) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
functionName,
|
|
76
|
+
generatedFile,
|
|
77
|
+
generatedLine,
|
|
78
|
+
generatedColumn
|
|
79
|
+
};
|
|
80
|
+
}
|
|
13
81
|
|
|
14
82
|
/**
|
|
15
83
|
* Remove query string, hash, diretórios e decodifica URL para obter o nome do bundle.
|
|
@@ -220,45 +288,14 @@ function symbolicateStack(stack, options) {
|
|
|
220
288
|
const traceMapCache = new Map();
|
|
221
289
|
|
|
222
290
|
const lines = stack.split(/\r?\n/);
|
|
223
|
-
// Ignora a primeira linha contendo a mensagem do erro (se não for um frame)
|
|
224
|
-
const candidateLines = lines.length > 1 && !lines[0].trim().startsWith('at ')
|
|
225
|
-
? lines.slice(1)
|
|
226
|
-
: lines;
|
|
227
|
-
|
|
228
|
-
for (const line of candidateLines) {
|
|
229
|
-
let parsedFunctionName = undefined;
|
|
230
|
-
let generatedFile = undefined;
|
|
231
|
-
let lineStr = undefined;
|
|
232
|
-
let colStr = undefined;
|
|
233
|
-
|
|
234
|
-
const withFnMatch = line.match(FRAME_WITH_FUNCTION);
|
|
235
|
-
if (withFnMatch) {
|
|
236
|
-
parsedFunctionName = withFnMatch[1];
|
|
237
|
-
generatedFile = withFnMatch[2];
|
|
238
|
-
lineStr = withFnMatch[3];
|
|
239
|
-
colStr = withFnMatch[4];
|
|
240
|
-
} else {
|
|
241
|
-
const withoutFnMatch = line.match(FRAME_WITHOUT_FUNCTION);
|
|
242
|
-
if (withoutFnMatch) {
|
|
243
|
-
generatedFile = withoutFnMatch[1];
|
|
244
|
-
lineStr = withoutFnMatch[2];
|
|
245
|
-
colStr = withoutFnMatch[3];
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
291
|
|
|
249
|
-
|
|
250
|
-
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
const parsed = parseStackFrame(line);
|
|
294
|
+
if (!parsed) {
|
|
251
295
|
continue;
|
|
252
296
|
}
|
|
253
297
|
|
|
254
|
-
const generatedLine =
|
|
255
|
-
const generatedColumn = parseInt(colStr, 10);
|
|
256
|
-
|
|
257
|
-
// Valida que linha e coluna são inteiros positivos
|
|
258
|
-
if (!Number.isInteger(generatedLine) || generatedLine <= 0 ||
|
|
259
|
-
!Number.isInteger(generatedColumn) || generatedColumn <= 0) {
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
298
|
+
const { functionName, generatedFile, generatedLine, generatedColumn } = parsed;
|
|
262
299
|
|
|
263
300
|
const bundleName = extractBundleName(generatedFile);
|
|
264
301
|
const matchingMaps = bundleMapFiles.get(bundleName);
|
|
@@ -267,7 +304,7 @@ function symbolicateStack(stack, options) {
|
|
|
267
304
|
generatedFile,
|
|
268
305
|
generatedLine,
|
|
269
306
|
generatedColumn,
|
|
270
|
-
...(
|
|
307
|
+
...(functionName ? { functionName } : {})
|
|
271
308
|
};
|
|
272
309
|
|
|
273
310
|
if (!matchingMaps || matchingMaps.length === 0) {
|
|
@@ -284,13 +321,17 @@ function symbolicateStack(stack, options) {
|
|
|
284
321
|
}
|
|
285
322
|
|
|
286
323
|
const mapPath = matchingMaps[0];
|
|
287
|
-
let
|
|
288
|
-
if (!
|
|
324
|
+
let cached = traceMapCache.get(mapPath);
|
|
325
|
+
if (!cached) {
|
|
289
326
|
try {
|
|
290
327
|
const rawContent = fs.readFileSync(mapPath, 'utf-8');
|
|
291
328
|
const parsedJson = JSON.parse(rawContent);
|
|
292
|
-
|
|
293
|
-
|
|
329
|
+
const declaredBundle = parsedJson.file && typeof parsedJson.file === 'string'
|
|
330
|
+
? extractBundleName(parsedJson.file)
|
|
331
|
+
: null;
|
|
332
|
+
const traceMap = new TraceMap(parsedJson);
|
|
333
|
+
cached = { traceMap, declaredBundle };
|
|
334
|
+
traceMapCache.set(mapPath, cached);
|
|
294
335
|
} catch (err) {
|
|
295
336
|
result.frames.push(unresolvedFrame);
|
|
296
337
|
result.unresolvedCount++;
|
|
@@ -299,7 +340,19 @@ function symbolicateStack(stack, options) {
|
|
|
299
340
|
}
|
|
300
341
|
}
|
|
301
342
|
|
|
302
|
-
|
|
343
|
+
if (cached.declaredBundle && cached.declaredBundle !== bundleName) {
|
|
344
|
+
result.frames.push(unresolvedFrame);
|
|
345
|
+
result.unresolvedCount++;
|
|
346
|
+
const mapName = path.basename(mapPath);
|
|
347
|
+
result.warnings.push(
|
|
348
|
+
`Source map ${mapName} declara o bundle ${cached.declaredBundle}, mas o frame pertence a ${bundleName}.`
|
|
349
|
+
);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const traceMap = cached.traceMap;
|
|
354
|
+
|
|
355
|
+
// A coluna da stack do navegador (V8/Firefox/Safari) é 1-based, enquanto o TraceMap espera coluna 0-based
|
|
303
356
|
const lookupColumn = generatedColumn > 0 ? generatedColumn - 1 : 0;
|
|
304
357
|
let original = null;
|
|
305
358
|
try {
|
|
@@ -326,7 +379,7 @@ function symbolicateStack(stack, options) {
|
|
|
326
379
|
sourceFile: normalizeSourceFile(original.source),
|
|
327
380
|
sourceLine: original.line,
|
|
328
381
|
sourceColumn: original.column,
|
|
329
|
-
functionName: original.name ??
|
|
382
|
+
...((original.name ?? functionName) ? { functionName: original.name ?? functionName } : {})
|
|
330
383
|
};
|
|
331
384
|
|
|
332
385
|
const { context, warning } = extractContext(traceMap, original.source, original.line, contextLines);
|
|
@@ -388,6 +441,7 @@ Argumentos obrigatórios:
|
|
|
388
441
|
--sourcemaps <diretório> Diretório contendo os arquivos .map
|
|
389
442
|
|
|
390
443
|
Opções adicionais:
|
|
444
|
+
--release <versão> Versão da release para validação de compatibilidade
|
|
391
445
|
--output <arquivo> Caminho do arquivo de saída (padrão: <incidente>.symbolicated.ffr.json)
|
|
392
446
|
--context-lines <número> Quantidade de linhas de contexto antes e depois do erro (0-10, padrão: 3)
|
|
393
447
|
-h, --help Mostra esta mensagem de ajuda
|
|
@@ -422,6 +476,7 @@ async function run(args = []) {
|
|
|
422
476
|
let incidentPath = null;
|
|
423
477
|
let sourcemapsDir = null;
|
|
424
478
|
let outputPath = null;
|
|
479
|
+
let releaseArg = null;
|
|
425
480
|
let contextLines = 3;
|
|
426
481
|
|
|
427
482
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -434,6 +489,13 @@ async function run(args = []) {
|
|
|
434
489
|
sourcemapsDir = args[++i];
|
|
435
490
|
} else if (arg === '--output') {
|
|
436
491
|
outputPath = args[++i];
|
|
492
|
+
} else if (arg === '--release') {
|
|
493
|
+
releaseArg = args[++i];
|
|
494
|
+
if (!releaseArg || releaseArg.startsWith('-')) {
|
|
495
|
+
console.error('[Backtrack] Erro: Opção --release exige uma versão.');
|
|
496
|
+
process.exitCode = 1;
|
|
497
|
+
return 1;
|
|
498
|
+
}
|
|
437
499
|
} else if (arg === '--context-lines') {
|
|
438
500
|
const val = parseInt(args[++i], 10);
|
|
439
501
|
if (!Number.isNaN(val)) {
|
|
@@ -546,37 +608,112 @@ async function run(args = []) {
|
|
|
546
608
|
}
|
|
547
609
|
}
|
|
548
610
|
|
|
549
|
-
let errorEventsCount = 0;
|
|
550
|
-
let totalResolvedFrames = 0;
|
|
551
|
-
let totalUnresolvedFrames = 0;
|
|
552
611
|
const allWarnings = [];
|
|
553
612
|
|
|
613
|
+
const artifactRelease = artifact.context && typeof artifact.context.release === 'string'
|
|
614
|
+
? artifact.context.release
|
|
615
|
+
: undefined;
|
|
616
|
+
|
|
617
|
+
let releaseStatusMsg = null;
|
|
618
|
+
|
|
619
|
+
if (releaseArg) {
|
|
620
|
+
if (artifactRelease) {
|
|
621
|
+
if (artifactRelease !== releaseArg) {
|
|
622
|
+
console.error(
|
|
623
|
+
`[Backtrack] Erro: Release informada (${releaseArg}) é incompatível com a release do incidente (${artifactRelease}).`
|
|
624
|
+
);
|
|
625
|
+
process.exitCode = 1;
|
|
626
|
+
return 1;
|
|
627
|
+
} else {
|
|
628
|
+
releaseStatusMsg = `Release: ${releaseArg} (release informada compatível)`;
|
|
629
|
+
}
|
|
630
|
+
} else {
|
|
631
|
+
allWarnings.push('A versão da release não pôde ser confirmada.');
|
|
632
|
+
}
|
|
633
|
+
} else if (artifactRelease) {
|
|
634
|
+
releaseStatusMsg = `Release do incidente: ${artifactRelease}`;
|
|
635
|
+
allWarnings.push(`Os source maps devem pertencer exatamente à release ${artifactRelease}.`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let errorEventsCount = 0;
|
|
639
|
+
let exceptionResolvedFrames = 0;
|
|
640
|
+
let exceptionUnresolvedFrames = 0;
|
|
641
|
+
let reactResolvedFrames = 0;
|
|
642
|
+
let reactUnresolvedFrames = 0;
|
|
643
|
+
|
|
554
644
|
const newTimeline = artifact.timeline.map((event) => {
|
|
555
|
-
if (event.type !== 'error'
|
|
645
|
+
if (event.type !== 'error') {
|
|
646
|
+
return event;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const hasStack = typeof event.stack === 'string' && event.stack.trim().length > 0;
|
|
650
|
+
const hasComponentStack = typeof event.componentStack === 'string' && event.componentStack.trim().length > 0;
|
|
651
|
+
|
|
652
|
+
if (!hasStack && !hasComponentStack) {
|
|
556
653
|
return event;
|
|
557
654
|
}
|
|
558
655
|
|
|
559
656
|
errorEventsCount++;
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
657
|
+
|
|
658
|
+
let stackFrames = [];
|
|
659
|
+
let stackResolvedCount = 0;
|
|
660
|
+
if (hasStack) {
|
|
661
|
+
const stackResult = symbolicateStack(event.stack, {
|
|
662
|
+
sourceMapsDirectory: sourcemapsDir,
|
|
663
|
+
contextLines
|
|
664
|
+
});
|
|
665
|
+
stackResolvedCount = stackResult.resolvedCount;
|
|
666
|
+
exceptionResolvedFrames += stackResult.resolvedCount;
|
|
667
|
+
exceptionUnresolvedFrames += stackResult.unresolvedCount;
|
|
668
|
+
if (stackResult.warnings && stackResult.warnings.length > 0) {
|
|
669
|
+
allWarnings.push(...stackResult.warnings);
|
|
670
|
+
}
|
|
671
|
+
stackFrames = stackResult.frames;
|
|
569
672
|
}
|
|
570
673
|
|
|
674
|
+
let componentFrames = [];
|
|
675
|
+
let componentResolvedCount = 0;
|
|
676
|
+
if (hasComponentStack) {
|
|
677
|
+
const componentResult = symbolicateStack(event.componentStack, {
|
|
678
|
+
sourceMapsDirectory: sourcemapsDir,
|
|
679
|
+
contextLines
|
|
680
|
+
});
|
|
681
|
+
componentResolvedCount = componentResult.resolvedCount;
|
|
682
|
+
reactResolvedFrames += componentResult.resolvedCount;
|
|
683
|
+
reactUnresolvedFrames += componentResult.unresolvedCount;
|
|
684
|
+
if (componentResult.warnings && componentResult.warnings.length > 0) {
|
|
685
|
+
allWarnings.push(...componentResult.warnings);
|
|
686
|
+
}
|
|
687
|
+
componentFrames = componentResult.frames;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Preserva stack e componentStack exatamente como recebidas.
|
|
691
|
+
// Não remove resolvedStack ou resolvedComponentStack existente se a nova execução não resolver nada.
|
|
692
|
+
const resolvedStack = stackFrames.length > 0 &&
|
|
693
|
+
(stackResolvedCount > 0 || !event.resolvedStack?.length)
|
|
694
|
+
? stackFrames
|
|
695
|
+
: event.resolvedStack;
|
|
696
|
+
|
|
697
|
+
const resolvedComponentStack = componentFrames.length > 0 &&
|
|
698
|
+
(componentResolvedCount > 0 || !event.resolvedComponentStack?.length)
|
|
699
|
+
? componentFrames
|
|
700
|
+
: event.resolvedComponentStack;
|
|
701
|
+
|
|
571
702
|
return {
|
|
572
703
|
...event,
|
|
573
|
-
...(
|
|
574
|
-
? { resolvedStack
|
|
704
|
+
...(resolvedStack && resolvedStack.length > 0
|
|
705
|
+
? { resolvedStack }
|
|
706
|
+
: {}),
|
|
707
|
+
...(resolvedComponentStack && resolvedComponentStack.length > 0
|
|
708
|
+
? { resolvedComponentStack }
|
|
575
709
|
: {})
|
|
576
710
|
};
|
|
577
711
|
});
|
|
578
712
|
|
|
579
|
-
|
|
713
|
+
const totalResolved = exceptionResolvedFrames + reactResolvedFrames;
|
|
714
|
+
const totalUnresolved = exceptionUnresolvedFrames + reactUnresolvedFrames;
|
|
715
|
+
|
|
716
|
+
if (totalResolved === 0 && totalUnresolved === 0) {
|
|
580
717
|
console.log('[Backtrack] Nenhuma stack compatível foi encontrada.');
|
|
581
718
|
process.exitCode = 0;
|
|
582
719
|
return 0;
|
|
@@ -584,16 +721,21 @@ async function run(args = []) {
|
|
|
584
721
|
|
|
585
722
|
const uniqueWarnings = Array.from(new Set(allWarnings));
|
|
586
723
|
|
|
587
|
-
if (
|
|
724
|
+
if (totalUnresolved > 0) {
|
|
588
725
|
console.log('[Backtrack] Symbolication parcial.\n');
|
|
589
726
|
} else {
|
|
590
727
|
console.log('[Backtrack] Symbolication concluída.\n');
|
|
591
728
|
}
|
|
592
729
|
|
|
593
730
|
console.log(`Incidente: ${artifact.incident.id}`);
|
|
731
|
+
if (releaseStatusMsg) {
|
|
732
|
+
console.log(releaseStatusMsg);
|
|
733
|
+
}
|
|
594
734
|
console.log(`Erros encontrados: ${errorEventsCount}`);
|
|
595
|
-
console.log(`Frames resolvidos: ${
|
|
596
|
-
console.log(`Frames não resolvidos: ${
|
|
735
|
+
console.log(`Frames da exceção resolvidos: ${exceptionResolvedFrames}`);
|
|
736
|
+
console.log(`Frames da exceção não resolvidos: ${exceptionUnresolvedFrames}`);
|
|
737
|
+
console.log(`Frames React resolvidos: ${reactResolvedFrames}`);
|
|
738
|
+
console.log(`Frames React não resolvidos: ${reactUnresolvedFrames}`);
|
|
597
739
|
console.log(`Saída: ${resolvedOutput}`);
|
|
598
740
|
|
|
599
741
|
if (uniqueWarnings.length > 0) {
|
|
@@ -642,6 +784,7 @@ if (require.main === module) {
|
|
|
642
784
|
}
|
|
643
785
|
|
|
644
786
|
module.exports = {
|
|
787
|
+
parseStackFrame,
|
|
645
788
|
symbolicateStack,
|
|
646
789
|
FRAME_WITH_FUNCTION,
|
|
647
790
|
FRAME_WITHOUT_FUNCTION,
|