@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.
- package/README.md +80 -3
- package/android/build.gradle +145 -0
- package/dist/cjs/config/getRNInstrumentations.js +3 -1
- package/dist/cjs/config/getRNInstrumentations.js.map +1 -1
- package/dist/cjs/config/makeRNConfig.js +7 -4
- package/dist/cjs/config/makeRNConfig.js.map +1 -1
- package/dist/cjs/config/types.js.map +1 -1
- package/dist/cjs/generated/faroRNPackageMeta.js +1 -1
- package/dist/cjs/generated/faroRNPackageMeta.js.map +1 -1
- package/dist/cjs/instrumentations/errors/index.js +3 -2
- package/dist/cjs/instrumentations/errors/index.js.map +1 -1
- package/dist/cjs/instrumentations/errors/stackTraceParser.js +98 -37
- package/dist/cjs/instrumentations/errors/stackTraceParser.js.map +1 -1
- package/dist/cjs/util/performanceObserverPolyfill.js +32 -3
- package/dist/cjs/util/performanceObserverPolyfill.js.map +1 -1
- package/dist/esm/config/getRNInstrumentations.js +3 -1
- package/dist/esm/config/getRNInstrumentations.js.map +1 -1
- package/dist/esm/config/makeRNConfig.js +7 -4
- package/dist/esm/config/makeRNConfig.js.map +1 -1
- package/dist/esm/config/types.js.map +1 -1
- package/dist/esm/generated/faroRNPackageMeta.js +1 -1
- package/dist/esm/generated/faroRNPackageMeta.js.map +1 -1
- package/dist/esm/instrumentations/errors/index.js +3 -2
- package/dist/esm/instrumentations/errors/index.js.map +1 -1
- package/dist/esm/instrumentations/errors/stackTraceParser.js +97 -37
- package/dist/esm/instrumentations/errors/stackTraceParser.js.map +1 -1
- package/dist/esm/util/performanceObserverPolyfill.js +32 -3
- package/dist/esm/util/performanceObserverPolyfill.js.map +1 -1
- package/dist/types/config/types.d.ts +5 -0
- package/dist/types/generated/faroRNPackageMeta.d.ts +1 -1
- package/dist/types/instrumentations/errors/index.d.ts +5 -0
- package/dist/types/instrumentations/errors/stackTraceParser.d.ts +19 -3
- package/dist/types/util/performanceObserverPolyfill.d.ts +5 -2
- package/ios/faro-upload-composed-source-map.sh +87 -0
- package/package.json +7 -10
- package/react-native.config.js +9 -1
- package/src/config/getRNInstrumentations.ts +5 -1
- package/src/config/makeRNConfig.test.ts +11 -0
- package/src/config/makeRNConfig.ts +7 -4
- package/src/config/types.ts +5 -0
- package/src/generated/faroRNPackageMeta.ts +1 -1
- package/src/initialize.test.ts +25 -0
- package/src/instrumentations/errors/index.ts +25 -8
- package/src/instrumentations/errors/stackTraceParser.test.ts +65 -0
- package/src/instrumentations/errors/stackTraceParser.ts +121 -37
- package/src/util/performanceObserverPolyfill.test.ts +48 -0
- 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.
|
|
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.
|
|
83
|
-
"@types/react": "
|
|
84
|
-
"@types/react-native": "
|
|
85
|
-
"react-native-mmkv": "
|
|
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": "
|
|
87
|
+
"gitHead": "65b49f4c649e3400c74495257d33bd28d9af4b58"
|
|
91
88
|
}
|
package/react-native.config.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
69
|
-
|
|
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 ??
|
|
101
|
+
parseStacktrace: config.parseStacktrace ?? createParseStacktrace(releaseBundleFilename),
|
|
99
102
|
paused: config.paused ?? false,
|
|
100
103
|
preventGlobalExposure: config.preventGlobalExposure ?? false,
|
|
101
104
|
unpatchedConsole: config.unpatchedConsole ?? defaultUnpatchedConsole,
|
package/src/config/types.ts
CHANGED
|
@@ -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.
|
|
5
|
+
export const FARO_REACT_NATIVE_NPM_VERSION = '1.2.0' as const;
|
package/src/initialize.test.ts
CHANGED
|
@@ -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(
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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 =
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
});
|