Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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))

## 8.21.0

### Features
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 || {};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
event.debug_meta = 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