diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d09d6c1a..30cfcb16d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,6 @@ ## Unreleased -### Fixes - -- Make the `RNSentry` SPEC CHECKSUM in `Podfile.lock` machine-independent ([#6534](https://github.com/getsentry/sentry-react-native/pull/6534)) - - The prebuilt `Sentry.xcframework` is now referenced through a `$(PODS_ROOT)/sentry-xcframeworks/…` symlink instead of the absolute per-user cache path, so `Podfile.lock` no longer churns between developers and CI. Expect a one-time `RNSentry` checksum change on the next `pod install`; the `SENTRY_XCFRAMEWORK_CACHE_DIR=/tmp/…` workaround is no longer needed. - ### Features - Add `enableMetricKit` option to enable the iOS MetricKit integration ([#6540](https://github.com/getsentry/sentry-react-native/pull/6540)) @@ -31,10 +25,13 @@ 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)) - `sentry-expo-upload-sourcemaps` now reads plugin config when the plugin is registered as `@sentry/react-native` ([#6543](https://github.com/getsentry/sentry-react-native/pull/6543)) +- Make the `RNSentry` SPEC CHECKSUM in `Podfile.lock` machine-independent ([#6534](https://github.com/getsentry/sentry-react-native/pull/6534)) + + The prebuilt `Sentry.xcframework` is now referenced through a `$(PODS_ROOT)/sentry-xcframeworks/…` symlink instead of the absolute per-user cache path, so `Podfile.lock` no longer churns between developers and CI. Expect a one-time `RNSentry` checksum change on the next `pod install`; the `SENTRY_XCFRAMEWORK_CACHE_DIR=/tmp/…` workaround is no longer needed. ### Dependencies diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 9733b6ffc3..e36c2d796d 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -210,6 +210,9 @@ export function createTimeToInitialDisplay(input: { useFocusEffect: (callback: () => void) => void; }): React_2.ComponentType; +// @public +export const debugMetaIntegration: () => Integration; + // @public export const debugSymbolicatorIntegration: () => Integration; diff --git a/packages/core/src/js/integrations/debugmeta.ts b/packages/core/src/js/integrations/debugmeta.ts new file mode 100644 index 0000000000..7d2246a876 --- /dev/null +++ b/packages/core/src/js/integrations/debugmeta.ts @@ -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 + ); +} diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index b6f356bb1a..2d380ac928 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -16,6 +16,7 @@ import { browserLinkedErrorsIntegration, createNativeFramesIntegrations, createReactNativeRewriteFrames, + debugMetaIntegration, debugSymbolicatorIntegration, dedupeIntegration, deviceContextIntegration, @@ -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()); diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 1630272bb4..d7ccc12488 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -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'; diff --git a/packages/core/test/integrations/debugmeta.test.ts b/packages/core/test/integrations/debugmeta.test.ts new file mode 100644 index 0000000000..3573808b54 --- /dev/null +++ b/packages/core/test/integrations/debugmeta.test.ts @@ -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; + + 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 = {}; + 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]); + }); + }); +});