Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@

Apps on the Old Architecture now get the same aggregate, span attribution and slow call breadcrumbs as TurboModules. An `arch: 'new' | 'legacy'` field distinguishes the two sources. Opt in with `turboModuleContextIntegration({ enableLegacyNativeModules: true })`.

### Fixes

- Attach `debug_meta` to JS error events on Hermes when the Debug ID stack match fails ([#6545](https://github.com/getsentry/sentry-react-native/pull/6545))

### Dependencies

- Bump Android SDK from v8.50.1 to v8.51.0 ([#6539](https://github.com/getsentry/sentry-react-native/pull/6539))
Expand Down
3 changes: 3 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ export function createTimeToInitialDisplay(input: {
useFocusEffect: (callback: () => void) => void;
}): React_2.ComponentType<TimeToDisplayProps>;

// @public
export const debugMetaIntegration: () => Integration;

// @public
export const debugSymbolicatorIntegration: () => Integration;

Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/js/integrations/debugmeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Event, Integration } from '@sentry/core';

import { getDebugMetadata } from '../profiling/debugid';

const INTEGRATION_NAME = 'DebugMeta';

/**
* Adds `debug_meta.images` to error events based on the Debug ID injected by
* Metro (`_sentryDebugIds`).
*
* React Native (Hermes) ships a single JS bundle per platform with a canonical
* `code_file` (`app:///index.android.bundle` / `app:///main.jsbundle`). This
* makes the direct Debug ID lookup used by profiling (`getDebugMetadata`) safe
* to reuse for all error events.
*
* The core `prepareEvent` pipeline already attempts this via `applyDebugIds` ->
* `applyDebugMeta`, but that path re-parses the premodule `Error().stack` and
* requires an exact filename match against the exception frames, which is
* fragile for Hermes premodule stacks. When it fails, events ship without
* `debug_meta` and symbolication falls back to release + filename matching.
*
* Ordering matters: core runs `applyDebugIds` (which sets `frame.debug_id`)
* *before* event processors, and `applyDebugMeta` (which turns those into
* `debug_meta.images`) *after* them. This integration is an event processor, so
* to stay idempotent it detects a successful core match via `frame.debug_id`
* and backs off โ€” otherwise core would append a second, identical image.
*/
export const debugMetaIntegration = (): Integration => {
return {
name: INTEGRATION_NAME,
setupOnce: () => {
// noop
},
processEvent,
};
};

function processEvent(event: Event): Event {
// Only error/message events carry symbolicatable JS stack traces.
// Transactions, profiles and other event types are handled elsewhere.
if (event.type !== undefined) {
return event;
}

// If core's `applyDebugIds` already matched the premodule stack, its frames
// carry a `debug_id` and core will stamp `debug_meta.images` itself once event
// processors have run. Backing off here avoids emitting a duplicate image.
if (hasFrameWithDebugId(event)) {
return event;
}

const images = getDebugMetadata();
if (!images.length) {
return event;
}

event.debug_meta = event.debug_meta || {};
event.debug_meta.images = event.debug_meta.images || [];
const existing = event.debug_meta.images;

for (const image of images) {
// Defensive dedupe against any image already present (e.g. from another
// integration) so the same bundle is never listed twice.
const alreadyPresent = existing.some(
existingImage => 'debug_id' in existingImage && existingImage.debug_id === image.debug_id,
);
if (!alreadyPresent) {
existing.push(image);
}
}

return event;
}

function hasFrameWithDebugId(event: Event): boolean {
return (
event.exception?.values?.some(exception =>
exception.stacktrace?.frames?.some(frame => frame.debug_id !== undefined),
) ?? false
);
}
6 changes: 6 additions & 0 deletions packages/core/src/js/integrations/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
browserLinkedErrorsIntegration,
createNativeFramesIntegrations,
createReactNativeRewriteFrames,
debugMetaIntegration,
debugSymbolicatorIntegration,
dedupeIntegration,
deviceContextIntegration,
Expand Down Expand Up @@ -89,6 +90,11 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
integrations.push(reactNativeInfoIntegration());

integrations.push(createReactNativeRewriteFrames());
// Stamp debug_meta from the Metro-injected Debug ID. Registered after
// RewriteFrames so the canonical bundle filename is already in place. This
// backfills debug_meta for Hermes/Expo-OTA events where the core
// applyDebugIds path fails to match the premodule stack.
integrations.push(debugMetaIntegration());

if (options.enableNative) {
integrations.push(deviceContextIntegration());
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { reactNativeErrorHandlersIntegration } from './reactnativeerrorhandlers'
export { nativeLinkedErrorsIntegration } from './nativelinkederrors';
export { nativeReleaseIntegration } from './release';
export { eventOriginIntegration } from './eventorigin';
export { debugMetaIntegration } from './debugmeta';
export { sdkInfoIntegration } from './sdkinfo';
export { reactNativeInfoIntegration } from './reactnativeinfo';
export { modulesLoaderIntegration } from './modulesloader';
Expand Down
174 changes: 174 additions & 0 deletions packages/core/test/integrations/debugmeta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
jest.mock('../../src/js/profiling/debugid');

import type { Client, DebugImage, Event } from '@sentry/core';

import { debugMetaIntegration } from '../../src/js/integrations/debugmeta';
import { getDebugMetadata } from '../../src/js/profiling/debugid';

describe('Debug Meta integration', () => {
const mockedGetDebugMetadata = getDebugMetadata as jest.MockedFunction<typeof getDebugMetadata>;

const DEBUG_ID = '12345678-1234-1234-1234-1234567890ab';
const IMAGE: DebugImage = {
type: 'sourcemap',
code_file: 'app:///index.android.bundle',
debug_id: DEBUG_ID,
};

beforeEach(() => {
mockedGetDebugMetadata.mockReset();
});

const runIntegration = (event: Event): Event => {
const integration = debugMetaIntegration();
// processEvent is synchronous for error events
return integration.processEvent!(event, {}, {} as Client) as Event;
};

const errorEventWithFrame = (): Event => ({
exception: {
values: [{ stacktrace: { frames: [{ filename: 'app:///index.android.bundle', function: 'foo' }] } }],
},
});

describe('standalone behaviour', () => {
it('stamps debug_meta.images on error events when a Debug ID is present', () => {
// Arrange
mockedGetDebugMetadata.mockReturnValue([IMAGE]);

// Act
const event = runIntegration(errorEventWithFrame());

// Assert
expect(event.debug_meta?.images).toEqual([IMAGE]);
});

it('does not add debug_meta when no Debug ID is available', () => {
// Arrange
mockedGetDebugMetadata.mockReturnValue([]);

// Act
const event = runIntegration(errorEventWithFrame());

// Assert
expect(event.debug_meta).toBeUndefined();
});

it('skips non-error events (transactions, profiles)', () => {
// Arrange
mockedGetDebugMetadata.mockReturnValue([IMAGE]);

// Act
const event = runIntegration({ type: 'transaction' });

// Assert
expect(event.debug_meta).toBeUndefined();
expect(mockedGetDebugMetadata).not.toHaveBeenCalled();
});

it('backs off when a frame already carries a debug_id (core matched the stack)', () => {
// Arrange: core's applyDebugIds ran first and matched the premodule stack
mockedGetDebugMetadata.mockReturnValue([IMAGE]);
const event: Event = {
exception: {
values: [{ stacktrace: { frames: [{ filename: 'app:///index.android.bundle', debug_id: DEBUG_ID }] } }],
},
};

// Act
const processed = runIntegration(event);

// Assert: we do not stamp; core's later applyDebugMeta will handle it
expect(processed.debug_meta).toBeUndefined();
});

it('preserves unrelated pre-existing images and appends the bundle image', () => {
// Arrange: e.g. a native linked error already added its own image
mockedGetDebugMetadata.mockReturnValue([IMAGE]);
const nativeImage: DebugImage = {
type: 'macho',
debug_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
image_addr: '0x0',
};
const event: Event = {
debug_meta: { images: [nativeImage] },
...errorEventWithFrame(),
};

// Act
const processed = runIntegration(event);

// Assert
expect(processed.debug_meta?.images).toEqual([nativeImage, IMAGE]);
});
});

// Faithfully reproduces the core prepareEvent ordering to guard against emitting
// a duplicate image alongside core's own stamp:
// applyDebugIds (sets frame.debug_id) -> event processors (this integration) -> applyDebugMeta (stamps images)
describe('core prepareEvent pipeline ordering', () => {
// Local stand-ins matching @sentry/core's prepareEvent behaviour. Kept in the
// test (not imported) because core does not export them from its public entry.
const coreApplyDebugIds = (event: Event, matchFilename: string | null): void => {
event.exception?.values?.forEach(exception => {
exception.stacktrace?.frames?.forEach(frame => {
if (frame.filename && frame.filename === matchFilename) {
frame.debug_id = DEBUG_ID;
}
});
});
};
const coreApplyDebugMeta = (event: Event): void => {
const map: Record<string, string> = {};
event.exception?.values?.forEach(exception => {
exception.stacktrace?.frames?.forEach(frame => {
if (frame.debug_id) {
const key = frame.abs_path || frame.filename;
if (key) {
map[key] = frame.debug_id;
}
delete frame.debug_id;
}
});
});
if (Object.keys(map).length === 0) {
return;
}
event.debug_meta = event.debug_meta || {};
event.debug_meta.images = event.debug_meta.images || [];
Object.entries(map).forEach(([code_file, debug_id]) => {
event.debug_meta!.images!.push({ type: 'sourcemap', code_file, debug_id });
});
};

it('does not duplicate the image when core successfully matches the stack', () => {
// Arrange
mockedGetDebugMetadata.mockReturnValue([IMAGE]);
const event = errorEventWithFrame();

// Act: core matches, then our processor runs, then core stamps debug_meta
coreApplyDebugIds(event, 'app:///index.android.bundle');
runIntegration(event);
coreApplyDebugMeta(event);

// Assert: exactly one image (contributed by core, not doubled by us)
expect(event.debug_meta?.images).toHaveLength(1);
expect(event.debug_meta?.images?.[0]).toMatchObject({ debug_id: DEBUG_ID, type: 'sourcemap' });
});

it('stamps the image exactly once when core fails to match the Hermes stack', () => {
// Arrange
mockedGetDebugMetadata.mockReturnValue([IMAGE]);
const event = errorEventWithFrame();

// Act: core does not match (Hermes premodule stack); our processor fills the gap
coreApplyDebugIds(event, null);
runIntegration(event);
coreApplyDebugMeta(event);

// Assert: exactly one image, contributed by us
expect(event.debug_meta?.images).toHaveLength(1);
expect(event.debug_meta?.images).toEqual([IMAGE]);
});
});
});
Loading