@grafana/faro-react-native 1.1.0 → 1.2.1

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.
Files changed (40) hide show
  1. package/README.md +72 -0
  2. package/android/build.gradle +145 -0
  3. package/dist/cjs/config/getRNInstrumentations.js +3 -1
  4. package/dist/cjs/config/getRNInstrumentations.js.map +1 -1
  5. package/dist/cjs/config/makeRNConfig.js +7 -4
  6. package/dist/cjs/config/makeRNConfig.js.map +1 -1
  7. package/dist/cjs/config/types.js.map +1 -1
  8. package/dist/cjs/generated/faroRNPackageMeta.js +1 -1
  9. package/dist/cjs/generated/faroRNPackageMeta.js.map +1 -1
  10. package/dist/cjs/instrumentations/errors/index.js +3 -2
  11. package/dist/cjs/instrumentations/errors/index.js.map +1 -1
  12. package/dist/cjs/instrumentations/errors/stackTraceParser.js +98 -37
  13. package/dist/cjs/instrumentations/errors/stackTraceParser.js.map +1 -1
  14. package/dist/esm/config/getRNInstrumentations.js +3 -1
  15. package/dist/esm/config/getRNInstrumentations.js.map +1 -1
  16. package/dist/esm/config/makeRNConfig.js +7 -4
  17. package/dist/esm/config/makeRNConfig.js.map +1 -1
  18. package/dist/esm/config/types.js.map +1 -1
  19. package/dist/esm/generated/faroRNPackageMeta.js +1 -1
  20. package/dist/esm/generated/faroRNPackageMeta.js.map +1 -1
  21. package/dist/esm/instrumentations/errors/index.js +3 -2
  22. package/dist/esm/instrumentations/errors/index.js.map +1 -1
  23. package/dist/esm/instrumentations/errors/stackTraceParser.js +97 -37
  24. package/dist/esm/instrumentations/errors/stackTraceParser.js.map +1 -1
  25. package/dist/types/config/types.d.ts +5 -0
  26. package/dist/types/generated/faroRNPackageMeta.d.ts +1 -1
  27. package/dist/types/instrumentations/errors/index.d.ts +5 -0
  28. package/dist/types/instrumentations/errors/stackTraceParser.d.ts +19 -3
  29. package/ios/faro-upload-composed-source-map.sh +93 -0
  30. package/package.json +7 -10
  31. package/react-native.config.js +9 -1
  32. package/src/config/getRNInstrumentations.ts +5 -1
  33. package/src/config/makeRNConfig.test.ts +11 -0
  34. package/src/config/makeRNConfig.ts +7 -4
  35. package/src/config/types.ts +5 -0
  36. package/src/generated/faroRNPackageMeta.ts +1 -1
  37. package/src/initialize.test.ts +25 -0
  38. package/src/instrumentations/errors/index.ts +25 -8
  39. package/src/instrumentations/errors/stackTraceParser.test.ts +65 -0
  40. package/src/instrumentations/errors/stackTraceParser.ts +121 -37
@@ -40,6 +40,12 @@ export interface ErrorsInstrumentationOptions {
40
40
  * Older errors are removed when this limit is reached
41
41
  */
42
42
  maxDeduplicationEntries?: number;
43
+
44
+ /**
45
+ * Bundle filename for Hermes/minified release lines (`func@line:col`).
46
+ * Must match the Metro plugin source map `file` field (default `bundle.js`).
47
+ */
48
+ releaseBundleFilename?: string;
43
49
  }
44
50
 
45
51
  interface ErrorFingerprint {
@@ -80,7 +86,9 @@ export class ErrorsInstrumentation extends BaseInstrumentation {
80
86
 
81
87
  private originalErrorHandler?: ErrorHandlerCallback;
82
88
  private unhandledRejectionListener?: (event: PromiseRejectionEvent) => void;
83
- private options: Required<ErrorsInstrumentationOptions>;
89
+ private options: Required<Omit<ErrorsInstrumentationOptions, 'releaseBundleFilename'>> & {
90
+ releaseBundleFilename?: string;
91
+ };
84
92
  private errorFingerprints: ErrorFingerprint[] = [];
85
93
 
86
94
  constructor(options: ErrorsInstrumentationOptions = {}) {
@@ -90,6 +98,7 @@ export class ErrorsInstrumentation extends BaseInstrumentation {
90
98
  enableDeduplication: options.enableDeduplication ?? true,
91
99
  deduplicationWindow: options.deduplicationWindow ?? 5000,
92
100
  maxDeduplicationEntries: options.maxDeduplicationEntries ?? 50,
101
+ releaseBundleFilename: options.releaseBundleFilename,
93
102
  };
94
103
  }
95
104
 
@@ -123,10 +132,14 @@ export class ErrorsInstrumentation extends BaseInstrumentation {
123
132
  error: enhancedError,
124
133
  stackFrames,
125
134
  context,
126
- } = enhanceErrorWithContext(error, {
127
- mechanism: ErrorMechanism.UNCAUGHT,
128
- isFatal: String(isFatal ?? false),
129
- });
135
+ } = enhanceErrorWithContext(
136
+ error,
137
+ {
138
+ mechanism: ErrorMechanism.UNCAUGHT,
139
+ isFatal: String(isFatal ?? false),
140
+ },
141
+ { releaseBundleFilename: this.options.releaseBundleFilename }
142
+ );
130
143
 
131
144
  // Push error to Faro with enhanced data (type from error.name, matches Web SDK)
132
145
  this.api.pushError(enhancedError, {
@@ -182,9 +195,13 @@ export class ErrorsInstrumentation extends BaseInstrumentation {
182
195
  error: enhancedError,
183
196
  stackFrames,
184
197
  context,
185
- } = enhanceErrorWithContext(error, {
186
- mechanism: ErrorMechanism.UNHANDLED_REJECTION,
187
- });
198
+ } = enhanceErrorWithContext(
199
+ error,
200
+ {
201
+ mechanism: ErrorMechanism.UNHANDLED_REJECTION,
202
+ },
203
+ { releaseBundleFilename: this.options.releaseBundleFilename }
204
+ );
188
205
 
189
206
  // Type: use actual error type for Error objects; 'UnhandledRejection' for primitives (Web SDK)
190
207
  const errorType = isPrimitiveRejection ? primitiveUnhandledType : enhancedError.name || 'Error';
@@ -65,6 +65,29 @@ describe('Stack Trace Parser', () => {
65
65
  filename: '<unknown>',
66
66
  lineno: 123,
67
67
  colno: 456,
68
+ releaseLine: true,
69
+ });
70
+ });
71
+
72
+ it('should normalize Hermes Android "address at index.android.bundle" file segment', () => {
73
+ const line = ' at someFunction (address at index.android.bundle:42:15)';
74
+ const frame = parseStackTraceLine(line);
75
+
76
+ expect(frame).toEqual({
77
+ function: 'someFunction',
78
+ filename: 'index.android.bundle',
79
+ lineno: 42,
80
+ colno: 15,
81
+ });
82
+ });
83
+
84
+ it('should parse Hermes-style parenthesis frame with bundle filename', () => {
85
+ const line = ' at reportError (index.android.bundle:1:849989)';
86
+ expect(parseStackTraceLine(line)).toEqual({
87
+ function: 'reportError',
88
+ filename: 'index.android.bundle',
89
+ lineno: 1,
90
+ colno: 849989,
68
91
  });
69
92
  });
70
93
 
@@ -165,6 +188,39 @@ describe('Stack Trace Parser', () => {
165
188
  const faroFrames = toFaroStackFrames([]);
166
189
  expect(faroFrames).toEqual([]);
167
190
  });
191
+
192
+ it('maps release placeholders to bundle.js in Faro frames', () => {
193
+ const parsed = parseStackTraceLine('a@123:456');
194
+ expect(parsed).not.toBeNull();
195
+ const faroFrames = toFaroStackFrames([parsed!]);
196
+ expect(faroFrames[0]?.filename).toBe('bundle.js');
197
+ });
198
+
199
+ it('maps release placeholders to a custom bundle filename', () => {
200
+ const parsed = parseStackTraceLine('a@123:456');
201
+ expect(parsed).not.toBeNull();
202
+ const faroFrames = toFaroStackFrames([parsed!], { releaseBundleFilename: 'index.android.bundle' });
203
+ expect(faroFrames[0]?.filename).toBe('index.android.bundle');
204
+ });
205
+
206
+ it('collapses Hermes absolute .app bundle path to basename so uploads match ingest keys', () => {
207
+ const iosPath =
208
+ '/Users/ci/Library/Developer/CoreSimulator/Devices/ABC/Data/Containers/Bundle/Application/62A2/QuickPizza.app/main.jsbundle';
209
+ const parsed = parseStackTraceLine(` at reportHandledException (${iosPath}:1:1249030)`);
210
+ expect(parsed).not.toBeNull();
211
+ const faroFrames = toFaroStackFrames([parsed!], { releaseBundleFilename: 'main.jsbundle' });
212
+ expect(faroFrames[0]?.filename).toBe('main.jsbundle');
213
+ expect(faroFrames[0]?.lineno).toBe(1);
214
+ expect(faroFrames[0]?.colno).toBe(1249030);
215
+ });
216
+
217
+ it('collapses absolute Android artifact path ending in index.android.bundle', () => {
218
+ const apkPath = '/data/app/org.example.cp/base.apk/assets/index.android.bundle';
219
+ const parsed = parseStackTraceLine(` at reportError (${apkPath}:1:849989)`);
220
+ expect(parsed).not.toBeNull();
221
+ const faroFrames = toFaroStackFrames([parsed!], { releaseBundleFilename: 'index.android.bundle' });
222
+ expect(faroFrames[0]?.filename).toBe('index.android.bundle');
223
+ });
168
224
  });
169
225
 
170
226
  describe('getStackFramesFromError', () => {
@@ -243,5 +299,14 @@ describe('Stack Trace Parser', () => {
243
299
  expect(result.context).toHaveProperty('customField', 'customValue');
244
300
  expect(result.context).toHaveProperty('platform');
245
301
  });
302
+
303
+ it('applies stack parse options for release frames', () => {
304
+ const error = new Error('Test');
305
+ error.stack = 'global@10:20';
306
+
307
+ const result = enhanceErrorWithContext(error, undefined, { releaseBundleFilename: 'main.jsbundle' });
308
+
309
+ expect(result.stackFrames[0]?.filename).toBe('main.jsbundle');
310
+ });
246
311
  });
247
312
  });
@@ -28,35 +28,85 @@ const REACT_NATIVE_RELEASE_REGEX = /^(.+?)@(\d+):(\d+)$/;
28
28
  const REACT_NATIVE_ANONYMOUS_REGEX = /^\s*at\s+anonymous\s+\((.+?):(\d+):(\d+)\)$/;
29
29
  const METRO_BUNDLER_REGEX = /^\s*at\s+(?:Object\.)?(.+?)\s+\((.+?):(\d+):(\d+)\)$/;
30
30
 
31
+ /**
32
+ * Hermes (Android release) often reports the bundle segment as `address at index.android.bundle`
33
+ * inside parentheses. The collector hashes `frame.filename` for uploaded maps; it must match the
34
+ * source map `file` field (e.g. `index.android.bundle`), not the verbose Hermes label.
35
+ */
36
+ export function normalizeHermesStackFilename(filename: string): string {
37
+ const trimmed = filename.trim();
38
+ const m = /^address\s+at\s+(.+)$/i.exec(trimmed);
39
+ return m?.[1]?.trim() ?? filename;
40
+ }
41
+
42
+ /**
43
+ * iOS Simulator (and Android) stacks often carry full filesystem paths such as
44
+ * `…/Foo.app/main.jsbundle` while uploaded maps are keyed by the bundle basename (`main.jsbundle`)
45
+ * that matches Metro’s source map `file` field — same as ingest’s bundle lookup hashes.
46
+ */
47
+ function collapseBundledArtifactPath(filename: string, releaseBasename: string): string {
48
+ const key = releaseBasename.trim();
49
+ if (!key || filename === key) {
50
+ return filename;
51
+ }
52
+ const suffix = `/${key}`;
53
+ return filename.endsWith(suffix) ? key : filename;
54
+ }
55
+
31
56
  export interface ParsedStackFrame {
32
57
  function?: string;
33
58
  filename?: string;
34
59
  lineno?: number;
35
60
  colno?: number;
36
61
  isNative?: boolean;
62
+ /** Set when the line matched Hermes/minified `func@line:col` (no path). */
63
+ releaseLine?: boolean;
64
+ }
65
+
66
+ /** Which parser branch handled a stack line (internal). */
67
+ type ParsedStackLineKind =
68
+ | 'react_native_paren'
69
+ | 'metro_paren'
70
+ | 'anonymous_paren'
71
+ | 'native'
72
+ | 'release_func_at'
73
+ | 'unparsed';
74
+
75
+ const UNKNOWN_RELEASE_FILENAME = '<unknown>';
76
+
77
+ export interface StackFrameParseOptions {
78
+ /**
79
+ * Hermes / minified RN release lines use `func@line:col` with no file path. Those frames are tagged
80
+ * with `releaseLine` and use this as `filename` (default `bundle.js`). Match the Metro plugin’s
81
+ * source map `file` field (or `sourceMapFile` option there).
82
+ */
83
+ releaseBundleFilename?: string;
37
84
  }
38
85
 
39
86
  /**
40
- * Parse a single stack trace line into a structured frame
87
+ * Parse a single stack trace line into a structured frame and record how it was parsed.
41
88
  */
42
- export function parseStackTraceLine(line: string): ParsedStackFrame | null {
89
+ function parseStackTraceLineDetailed(line: string): { frame: ParsedStackFrame | null; kind: ParsedStackLineKind } {
43
90
  if (!line || typeof line !== 'string') {
44
- return null;
91
+ return { frame: null, kind: 'unparsed' };
45
92
  }
46
93
 
47
94
  const trimmedLine = line.trim();
48
95
  if (!trimmedLine) {
49
- return null;
96
+ return { frame: null, kind: 'unparsed' };
50
97
  }
51
98
 
52
99
  // Try standard React Native format: at functionName (file.js:123:45)
53
100
  let match = trimmedLine.match(REACT_NATIVE_STACK_REGEX);
54
101
  if (match && match[2] && match[3] && match[4]) {
55
102
  return {
56
- function: match[1] || '<anonymous>',
57
- filename: match[2],
58
- lineno: parseInt(match[3], 10),
59
- colno: parseInt(match[4], 10),
103
+ kind: 'react_native_paren',
104
+ frame: {
105
+ function: match[1] || '<anonymous>',
106
+ filename: normalizeHermesStackFilename(match[2]),
107
+ lineno: parseInt(match[3], 10),
108
+ colno: parseInt(match[4], 10),
109
+ },
60
110
  };
61
111
  }
62
112
 
@@ -64,10 +114,13 @@ export function parseStackTraceLine(line: string): ParsedStackFrame | null {
64
114
  match = trimmedLine.match(METRO_BUNDLER_REGEX);
65
115
  if (match && match[2] && match[3] && match[4]) {
66
116
  return {
67
- function: match[1] || '<anonymous>',
68
- filename: match[2],
69
- lineno: parseInt(match[3], 10),
70
- colno: parseInt(match[4], 10),
117
+ kind: 'metro_paren',
118
+ frame: {
119
+ function: match[1] || '<anonymous>',
120
+ filename: normalizeHermesStackFilename(match[2]),
121
+ lineno: parseInt(match[3], 10),
122
+ colno: parseInt(match[4], 10),
123
+ },
71
124
  };
72
125
  }
73
126
 
@@ -75,10 +128,13 @@ export function parseStackTraceLine(line: string): ParsedStackFrame | null {
75
128
  match = trimmedLine.match(REACT_NATIVE_ANONYMOUS_REGEX);
76
129
  if (match && match[1] && match[2] && match[3]) {
77
130
  return {
78
- function: '<anonymous>',
79
- filename: match[1],
80
- lineno: parseInt(match[2], 10),
81
- colno: parseInt(match[3], 10),
131
+ kind: 'anonymous_paren',
132
+ frame: {
133
+ function: '<anonymous>',
134
+ filename: normalizeHermesStackFilename(match[1]),
135
+ lineno: parseInt(match[2], 10),
136
+ colno: parseInt(match[3], 10),
137
+ },
82
138
  };
83
139
  }
84
140
 
@@ -86,9 +142,12 @@ export function parseStackTraceLine(line: string): ParsedStackFrame | null {
86
142
  match = trimmedLine.match(REACT_NATIVE_NATIVE_REGEX);
87
143
  if (match) {
88
144
  return {
89
- function: match[1] || '<native>',
90
- filename: '<native>',
91
- isNative: true,
145
+ kind: 'native',
146
+ frame: {
147
+ function: match[1] || '<native>',
148
+ filename: '<native>',
149
+ isNative: true,
150
+ },
92
151
  };
93
152
  }
94
153
 
@@ -96,15 +155,25 @@ export function parseStackTraceLine(line: string): ParsedStackFrame | null {
96
155
  match = trimmedLine.match(REACT_NATIVE_RELEASE_REGEX);
97
156
  if (match && match[2] && match[3]) {
98
157
  return {
99
- function: match[1] || '<anonymous>',
100
- filename: '<unknown>',
101
- lineno: parseInt(match[2], 10),
102
- colno: parseInt(match[3], 10),
158
+ kind: 'release_func_at',
159
+ frame: {
160
+ function: match[1] || '<anonymous>',
161
+ filename: UNKNOWN_RELEASE_FILENAME,
162
+ lineno: parseInt(match[2], 10),
163
+ colno: parseInt(match[3], 10),
164
+ releaseLine: true,
165
+ },
103
166
  };
104
167
  }
105
168
 
106
- // Couldn't parse this line
107
- return null;
169
+ return { frame: null, kind: 'unparsed' };
170
+ }
171
+
172
+ /**
173
+ * Parse a single stack trace line into a structured frame
174
+ */
175
+ export function parseStackTraceLine(line: string): ParsedStackFrame | null {
176
+ return parseStackTraceLineDetailed(line).frame;
108
177
  }
109
178
 
110
179
  /**
@@ -119,7 +188,7 @@ export function parseStackTrace(stackTrace: string): ParsedStackFrame[] {
119
188
  const frames: ParsedStackFrame[] = [];
120
189
 
121
190
  for (const line of lines) {
122
- const frame = parseStackTraceLine(line);
191
+ const { frame } = parseStackTraceLineDetailed(line);
123
192
  if (frame) {
124
193
  frames.push(frame);
125
194
  }
@@ -131,26 +200,40 @@ export function parseStackTrace(stackTrace: string): ParsedStackFrame[] {
131
200
  /**
132
201
  * Convert parsed stack frames to Faro's StackFrame format
133
202
  */
134
- export function toFaroStackFrames(parsedFrames: ParsedStackFrame[]): StackFrame[] {
135
- return parsedFrames.map((frame, _index) => ({
136
- filename: frame.filename || '<unknown>',
137
- function: frame.function || '<anonymous>',
138
- lineno: frame.lineno,
139
- colno: frame.colno,
140
- }));
203
+ export function toFaroStackFrames(parsedFrames: ParsedStackFrame[], options?: StackFrameParseOptions): StackFrame[] {
204
+ const releaseName = options?.releaseBundleFilename ?? 'bundle.js';
205
+ const keyedBasename =
206
+ typeof options?.releaseBundleFilename === 'string' && options.releaseBundleFilename.trim() !== ''
207
+ ? options.releaseBundleFilename.trim()
208
+ : null;
209
+
210
+ return parsedFrames.map((frame, _index) => {
211
+ let filename = normalizeHermesStackFilename(frame.filename || '<unknown>');
212
+ if (frame.releaseLine) {
213
+ filename = releaseName;
214
+ } else if (keyedBasename) {
215
+ filename = collapseBundledArtifactPath(filename, keyedBasename);
216
+ }
217
+ return {
218
+ filename,
219
+ function: frame.function || '<anonymous>',
220
+ lineno: frame.lineno,
221
+ colno: frame.colno,
222
+ };
223
+ });
141
224
  }
142
225
 
143
226
  /**
144
227
  * Extract and parse stack frames from an Error object
145
228
  */
146
- export function getStackFramesFromError(error: Error): StackFrame[] {
229
+ export function getStackFramesFromError(error: Error, options?: StackFrameParseOptions): StackFrame[] {
147
230
  if (!error || !error.stack) {
148
231
  return [];
149
232
  }
150
233
 
151
234
  try {
152
235
  const parsedFrames = parseStackTrace(error.stack);
153
- return toFaroStackFrames(parsedFrames);
236
+ return toFaroStackFrames(parsedFrames, options);
154
237
  } catch (_e) {
155
238
  // If parsing fails, return empty array
156
239
  return [];
@@ -173,13 +256,14 @@ export function getPlatformErrorContext(): Record<string, string> {
173
256
  */
174
257
  export function enhanceErrorWithContext(
175
258
  error: Error,
176
- additionalContext?: Record<string, string>
259
+ additionalContext?: Record<string, string>,
260
+ stackParseOptions?: StackFrameParseOptions
177
261
  ): {
178
262
  error: Error;
179
263
  stackFrames: StackFrame[];
180
264
  context: Record<string, string>;
181
265
  } {
182
- const stackFrames = getStackFramesFromError(error);
266
+ const stackFrames = getStackFramesFromError(error, stackParseOptions);
183
267
  const platformContext = getPlatformErrorContext();
184
268
 
185
269
  return {