@grafana/faro-react-native 1.0.0 → 1.2.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.
Files changed (47) hide show
  1. package/README.md +80 -3
  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/cjs/util/performanceObserverPolyfill.js +32 -3
  15. package/dist/cjs/util/performanceObserverPolyfill.js.map +1 -1
  16. package/dist/esm/config/getRNInstrumentations.js +3 -1
  17. package/dist/esm/config/getRNInstrumentations.js.map +1 -1
  18. package/dist/esm/config/makeRNConfig.js +7 -4
  19. package/dist/esm/config/makeRNConfig.js.map +1 -1
  20. package/dist/esm/config/types.js.map +1 -1
  21. package/dist/esm/generated/faroRNPackageMeta.js +1 -1
  22. package/dist/esm/generated/faroRNPackageMeta.js.map +1 -1
  23. package/dist/esm/instrumentations/errors/index.js +3 -2
  24. package/dist/esm/instrumentations/errors/index.js.map +1 -1
  25. package/dist/esm/instrumentations/errors/stackTraceParser.js +97 -37
  26. package/dist/esm/instrumentations/errors/stackTraceParser.js.map +1 -1
  27. package/dist/esm/util/performanceObserverPolyfill.js +32 -3
  28. package/dist/esm/util/performanceObserverPolyfill.js.map +1 -1
  29. package/dist/types/config/types.d.ts +5 -0
  30. package/dist/types/generated/faroRNPackageMeta.d.ts +1 -1
  31. package/dist/types/instrumentations/errors/index.d.ts +5 -0
  32. package/dist/types/instrumentations/errors/stackTraceParser.d.ts +19 -3
  33. package/dist/types/util/performanceObserverPolyfill.d.ts +5 -2
  34. package/ios/faro-upload-composed-source-map.sh +87 -0
  35. package/package.json +7 -10
  36. package/react-native.config.js +9 -1
  37. package/src/config/getRNInstrumentations.ts +5 -1
  38. package/src/config/makeRNConfig.test.ts +11 -0
  39. package/src/config/makeRNConfig.ts +7 -4
  40. package/src/config/types.ts +5 -0
  41. package/src/generated/faroRNPackageMeta.ts +1 -1
  42. package/src/initialize.test.ts +25 -0
  43. package/src/instrumentations/errors/index.ts +25 -8
  44. package/src/instrumentations/errors/stackTraceParser.test.ts +65 -0
  45. package/src/instrumentations/errors/stackTraceParser.ts +121 -37
  46. package/src/util/performanceObserverPolyfill.test.ts +48 -0
  47. package/src/util/performanceObserverPolyfill.ts +43 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/faro-react-native",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Faro instrumentations, metas, transports for React Native.",
5
5
  "keywords": [
6
6
  "observability",
@@ -61,16 +61,12 @@
61
61
  "react-native-device-info": "^11.1.0"
62
62
  },
63
63
  "peerDependencies": {
64
- "@grafana/faro-react-native-tracing": ">=1.0.0",
65
64
  "@react-navigation/native": ">=6.0.0",
66
65
  "react": ">=18.0.0",
67
66
  "react-native": ">=0.70.0",
68
67
  "react-native-mmkv": ">=2.0.0"
69
68
  },
70
69
  "peerDependenciesMeta": {
71
- "@grafana/faro-react-native-tracing": {
72
- "optional": true
73
- },
74
70
  "@react-navigation/native": {
75
71
  "optional": true
76
72
  },
@@ -79,13 +75,14 @@
79
75
  }
80
76
  },
81
77
  "devDependencies": {
82
- "@types/node": "24.10.1",
83
- "@types/react": "^19.1.1",
84
- "@types/react-native": "^0.73.0",
85
- "react-native-mmkv": "^4.3.1"
78
+ "@types/node": "24.12.3",
79
+ "@types/react": "19.2.14",
80
+ "@types/react-native": "0.73.0",
81
+ "react-native-mmkv": "4.3.1",
82
+ "react-native-nitro-modules": "0.35.6"
86
83
  },
87
84
  "publishConfig": {
88
85
  "access": "public"
89
86
  },
90
- "gitHead": "00172e294e05bcf2faa2193d01aabc43c3cc8134"
87
+ "gitHead": "65b49f4c649e3400c74495257d33bd28d9af4b58"
91
88
  }
@@ -1,8 +1,16 @@
1
1
  module.exports = {
2
2
  dependency: {
3
3
  platforms: {
4
- ios: {},
5
4
  android: {},
5
+ ios: {
6
+ scriptPhases: [
7
+ {
8
+ name: '[Faro] Upload composed source map (Release)',
9
+ path: './ios/faro-upload-composed-source-map.sh',
10
+ execution_position: 'any',
11
+ },
12
+ ],
13
+ },
6
14
  },
7
15
  },
8
16
  };
@@ -79,7 +79,11 @@ export function getRNInstrumentations(config: Partial<ReactNativeConfig> = {}):
79
79
 
80
80
  // Error reporting (Flutter: enableFlutterErrorReporting)
81
81
  if (enableErrorReporting) {
82
- instrumentations.push(new ErrorsInstrumentation());
82
+ instrumentations.push(
83
+ new ErrorsInstrumentation({
84
+ releaseBundleFilename: config.releaseBundleFilename,
85
+ })
86
+ );
83
87
  }
84
88
 
85
89
  // Console capture - not in Flutter SDK, RN-specific
@@ -127,4 +127,15 @@ describe('makeRNConfig', () => {
127
127
  (cfg as { preloadedSessionDeviceAttributes?: { device_id: string } }).preloadedSessionDeviceAttributes?.device_id
128
128
  ).toBe('test-preloaded-id');
129
129
  });
130
+
131
+ it('uses releaseBundleFilename in default parseStacktrace when set', () => {
132
+ const cfg = makeRNConfig({
133
+ ...base,
134
+ releaseBundleFilename: 'main.jsbundle',
135
+ });
136
+ const err = new Error('x');
137
+ err.stack = 'g@1:2';
138
+ const parsed = cfg.parseStacktrace?.(err);
139
+ expect(parsed?.frames?.[0]?.filename).toBe('main.jsbundle');
140
+ });
130
141
  });
@@ -65,9 +65,11 @@ function buildInstrumentations(config: ReactNativeConfig): Config['instrumentati
65
65
  /**
66
66
  * React Native stacktrace parser. Uses getStackFramesFromError for RN-specific formats.
67
67
  */
68
- const parseStacktrace: Config['parseStacktrace'] = (err) => ({
69
- frames: getStackFramesFromError(err),
70
- });
68
+ function createParseStacktrace(releaseBundleFilename: string | undefined): Config['parseStacktrace'] {
69
+ return (err) => ({
70
+ frames: getStackFramesFromError(err, { releaseBundleFilename }),
71
+ });
72
+ }
71
73
 
72
74
  /**
73
75
  * Creates a full Faro config from React Native flag-based config.
@@ -86,6 +88,7 @@ export function makeRNConfig(
86
88
  const transports = buildTransports(config);
87
89
  const instrumentations = buildInstrumentations(config);
88
90
 
91
+ const releaseBundleFilename = config.releaseBundleFilename;
89
92
  return {
90
93
  app: config.app,
91
94
  ...(preloadedSessionDeviceAttributes != null && {
@@ -95,7 +98,7 @@ export function makeRNConfig(
95
98
  globalObjectKey: config.globalObjectKey ?? defaultGlobalObjectKey,
96
99
  internalLoggerLevel: config.internalLoggerLevel ?? InternalLoggerLevel.ERROR,
97
100
  isolate: config.isolate ?? false,
98
- parseStacktrace: config.parseStacktrace ?? parseStacktrace,
101
+ parseStacktrace: config.parseStacktrace ?? createParseStacktrace(releaseBundleFilename),
99
102
  paused: config.paused ?? false,
100
103
  preventGlobalExposure: config.preventGlobalExposure ?? false,
101
104
  unpatchedConsole: config.unpatchedConsole ?? defaultUnpatchedConsole,
@@ -70,6 +70,11 @@ type ReactNativeConfigOverrides = {
70
70
  consoleCaptureOptions?: Config['consoleInstrumentation'];
71
71
  enableUserActions?: boolean;
72
72
  userActionsOptions?: Config['userActionsInstrumentation'];
73
+ /**
74
+ * Hermes / minified release stack lines omit a file path. This must match the Metro plugin
75
+ * source map top-level `file` (default `bundle.js`, or your `sourceMapFile` option there).
76
+ */
77
+ releaseBundleFilename?: string;
73
78
  };
74
79
 
75
80
  /**
@@ -2,4 +2,4 @@
2
2
  * NPM package identity for this build. Generated by scripts/sync-faro-rn-package-meta.mjs - do not edit.
3
3
  */
4
4
  export const FARO_REACT_NATIVE_NPM_NAME = '@grafana/faro-react-native' as const;
5
- export const FARO_REACT_NATIVE_NPM_VERSION = '1.0.0' as const;
5
+ export const FARO_REACT_NATIVE_NPM_VERSION = '1.2.0' as const;
@@ -8,10 +8,17 @@ import { SessionInstrumentation } from './instrumentations/session';
8
8
  import * as sessionAttributes from './instrumentations/session/sessionAttributes';
9
9
 
10
10
  describe('initializeFaro', () => {
11
+ const preambleKey = '__faroBundleId_test';
12
+
11
13
  beforeEach(() => {
12
14
  jest.clearAllMocks();
13
15
  jest.clearAllTimers();
14
16
  delete (global as any).faro;
17
+ delete (globalThis as Record<string, unknown>)[preambleKey];
18
+ });
19
+
20
+ afterEach(() => {
21
+ delete (globalThis as Record<string, unknown>)[preambleKey];
15
22
  });
16
23
 
17
24
  it('should initialize Faro', async () => {
@@ -123,4 +130,22 @@ describe('initializeFaro', () => {
123
130
 
124
131
  spy.mockRestore();
125
132
  });
133
+
134
+ it('should set meta.app.bundleId from Faro Metro preamble global (__faroBundleId_<app.name>)', async () => {
135
+ (globalThis as Record<string, unknown>)[preambleKey] = 'release-bundle-from-metro';
136
+
137
+ const faro = await initializeFaro(
138
+ mockConfig({
139
+ url: 'http://localhost:12345/collect',
140
+ transports: [new MockTransport()],
141
+ instrumentations: [new SessionInstrumentation()],
142
+ sessionTracking: {
143
+ enabled: true,
144
+ persistent: false,
145
+ },
146
+ })
147
+ );
148
+
149
+ expect(faro.metas.value.app?.bundleId).toBe('release-bundle-from-metro');
150
+ });
126
151
  });
@@ -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 {
@@ -0,0 +1,48 @@
1
+ import { applyPerformanceObserverPolyfill } from './performanceObserverPolyfill';
2
+
3
+ describe('applyPerformanceObserverPolyfill', () => {
4
+ const originalPerformance = global.performance;
5
+ const originalPerformanceObserver = global.PerformanceObserver;
6
+
7
+ afterEach(() => {
8
+ Object.defineProperty(global, 'performance', {
9
+ configurable: true,
10
+ value: originalPerformance,
11
+ writable: true,
12
+ });
13
+ Object.defineProperty(global, 'PerformanceObserver', {
14
+ configurable: true,
15
+ value: originalPerformanceObserver,
16
+ writable: true,
17
+ });
18
+ });
19
+
20
+ it('returns an empty list for unsupported resource timing lookups', () => {
21
+ const getEntriesByType = jest.fn(() => [{ entryType: 'mark', name: 'test' }]);
22
+ Object.defineProperty(global, 'performance', {
23
+ configurable: true,
24
+ value: { getEntriesByType },
25
+ writable: true,
26
+ });
27
+
28
+ applyPerformanceObserverPolyfill();
29
+
30
+ expect(global.performance.getEntriesByType('resource')).toEqual([]);
31
+ expect(getEntriesByType).not.toHaveBeenCalledWith('resource');
32
+ });
33
+
34
+ it('delegates non-resource timing lookups to the original implementation', () => {
35
+ const markEntry = { entryType: 'mark', name: 'test' };
36
+ const getEntriesByType = jest.fn(() => [markEntry]);
37
+ Object.defineProperty(global, 'performance', {
38
+ configurable: true,
39
+ value: { getEntriesByType },
40
+ writable: true,
41
+ });
42
+
43
+ applyPerformanceObserverPolyfill();
44
+
45
+ expect(global.performance.getEntriesByType('mark')).toEqual([markEntry]);
46
+ expect(getEntriesByType).toHaveBeenCalledWith('mark');
47
+ });
48
+ });