-
-
Notifications
You must be signed in to change notification settings - Fork 364
fix(core): Attach debug_meta to Hermes JS error events via Debug ID lookup #6545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
f40d5e0
fix(core): Attach debug_meta to Hermes JS error events via Debug ID lโฆ
antonis 3b10f42
chore: point changelog entry to PR #6545
antonis a3ec7e4
Merge branch 'main' into fix/6480-debug-meta-hermes
antonis e961cc8
Merge branch 'main' into fix/6480-debug-meta-hermes
antonis 9b3f3a6
Fix duplicate fixes section
antonis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.