@backtrack-js/browser 0.4.2 → 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 CHANGED
@@ -89,6 +89,7 @@ npx backtrack symbolize <incident> --sourcemaps <directory> [options]
89
89
  | `<incident>` | Path to an exported incident: `.json`, `.ffr.json`, `.gz` or `.ffr.json.gz`. Gzip is detected automatically. |
90
90
  | `--sourcemaps <directory>` | Directory containing your `.js.map`, `.mjs.map` and `.cjs.map` files. Searched recursively (e.g. `./dist`, `./build`). |
91
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. |
92
93
  | `--context-lines <number>` | Lines of original source shown before and after the failing line. Range 0–10, default 3. |
93
94
  | `-h, --help` | Show help. |
94
95
 
@@ -103,8 +104,10 @@ npx backtrack symbolize ./incidents/checkout-bug.ffr.json --sourcemaps ./dist --
103
104
 
104
105
  Incidente: inc_1727500000_3f2a
105
106
  Erros encontrados: 1
106
- Frames resolvidos: 4
107
- Frames não resolvidos: 0
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
108
111
  Saída: /projects/app/incidents/checkout-bug.symbolicated.ffr.json
109
112
  ```
110
113
 
@@ -125,10 +128,18 @@ applyCoupon src/cart/coupon.ts:42:18
125
128
  ### What it does (and doesn't do)
126
129
 
127
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.
128
- - **Maps V8 frames:** Finds error events that carry a stack and maps each minified frame with [@jridgewell/trace-mapping](https://github.com/jridgewell/trace-mapping).
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.
129
134
  - **Extracts source context:** Extracts the original code from `sourcesContent`, so your source maps must include it.
130
135
  - **Doesn't leak local paths:** File paths are normalized (e.g. `C:\Users\alice\...` or `/home/bob/...` become paths starting at `src/`).
131
- - **Atomic write:** Writes the result atomically (to a `.tmp` file, then renames) and stores it in a new `resolvedStack` field.
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.
132
143
 
133
144
  ---
134
145
 
@@ -201,15 +212,36 @@ const jsonBlob = new Blob([JSON.stringify(artifact, null, 2)], { type: 'applicat
201
212
  ```
202
213
 
203
214
  ### Exception Capture
204
- In React Error Boundaries or try/catch blocks:
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`:
205
218
 
206
219
  ```typescript
207
- recorder.captureException(error, {
208
- source: 'react',
209
- componentStack: errorInfo.componentStack
210
- });
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
+ }
211
227
  ```
212
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
+
213
245
  ---
214
246
 
215
247
  ## Remote Transport & beforeSend
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
- if (!generatedFile || !lineStr || !colStr) {
250
- // Ignora linhas que não correspondam a um frame
292
+ for (const line of lines) {
293
+ const parsed = parseStackFrame(line);
294
+ if (!parsed) {
251
295
  continue;
252
296
  }
253
297
 
254
- const generatedLine = parseInt(lineStr, 10);
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
- ...(parsedFunctionName ? { functionName: parsedFunctionName } : {})
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 traceMap = traceMapCache.get(mapPath);
288
- if (!traceMap) {
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
- traceMap = new TraceMap(parsedJson);
293
- traceMapCache.set(mapPath, traceMap);
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
- // A coluna da stack do navegador (V8) é 1-based, enquanto o TraceMap espera coluna 0-based
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 ?? parsedFunctionName
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' || !event.stack || typeof event.stack !== 'string') {
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
- const result = symbolicateStack(event.stack, {
561
- sourceMapsDirectory: sourcemapsDir,
562
- contextLines
563
- });
564
-
565
- totalResolvedFrames += result.resolvedCount;
566
- totalUnresolvedFrames += result.unresolvedCount;
567
- if (result.warnings && result.warnings.length > 0) {
568
- allWarnings.push(...result.warnings);
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
- ...(result.frames.length > 0
574
- ? { resolvedStack: result.frames }
704
+ ...(resolvedStack && resolvedStack.length > 0
705
+ ? { resolvedStack }
706
+ : {}),
707
+ ...(resolvedComponentStack && resolvedComponentStack.length > 0
708
+ ? { resolvedComponentStack }
575
709
  : {})
576
710
  };
577
711
  });
578
712
 
579
- if (totalResolvedFrames === 0 && totalUnresolvedFrames === 0) {
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 (totalUnresolvedFrames > 0) {
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: ${totalResolvedFrames}`);
596
- console.log(`Frames não resolvidos: ${totalUnresolvedFrames}`);
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,