diff --git a/CHANGELOG.md b/CHANGELOG.md index fdea17ff2e44..3a3a8a0ac38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,25 @@ Sentry.metrics.count('response_time', 283.33, { unit: 'millisecond' }); ``` +- **feat(wasm): Add applicationKey option for third-party error filtering ([#18762])(https://github.com/getsentry/sentry-javascript/pull/18762)** + + Adds support for applying an application key to WASM stack frames that can be then used in the `thirdPartyErrorFilterIntegration` for detection of first-party code. + + Usage: + + ```js + Sentry.init({ + integrations: [ + // Integration order matters: wasmIntegration needs to be before thirdPartyErrorFilterIntegration + wasmIntegration({ applicationKey: 'your-custom-application-key' }), ←───┐ + thirdPartyErrorFilterIntegration({ │ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', ├─ matching keys + filterKeys: ['your-custom-application-key'] ←─────────────────────────┘ + }), + ], + }); + ``` + - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) ## 10.32.1 diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js new file mode 100644 index 000000000000..912a4aafd728 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/browser'; +import { thirdPartyErrorFilterIntegration } from '@sentry/browser'; +import { wasmIntegration } from '@sentry/wasm'; + +// Simulate what the bundler plugin would inject to mark JS code as first-party +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:wasm-test-app': true, + }, +); + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + wasmIntegration({ applicationKey: 'wasm-test-app' }), + thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['wasm-test-app'], + }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js new file mode 100644 index 000000000000..74d9e73aa6f3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js @@ -0,0 +1,35 @@ +// Simulate what the bundler plugin would inject to mark this JS file as first-party +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:wasm-test-app': true, + }, +); + +async function runWasm() { + function crash() { + throw new Error('WASM triggered error'); + } + + const { instance } = await WebAssembly.instantiateStreaming(fetch('https://localhost:5887/simple.wasm'), { + env: { + external_func: crash, + }, + }); + + instance.exports.internal_func(); +} + +runWasm(); diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts new file mode 100644 index 000000000000..f0ebf27d6aef --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { shouldSkipWASMTests } from '../../../utils/wasmHelpers'; + +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode because both +// wasmIntegration and thirdPartyErrorFilterIntegration are only available in NPM packages +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + +sentryTest( + 'WASM frames should be recognized as first-party when applicationKey is configured', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/simple.wasm', route => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '../simple.wasm')); + + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); + + const errorEventPromise = waitForErrorRequest(page, e => { + return e.exception?.values?.[0]?.value === 'WASM triggered error'; + }); + + await page.goto(url); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + + expect(errorEvent.tags?.third_party_code).toBeUndefined(); + + // Verify we have WASM frames in the stacktrace + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filename: expect.stringMatching(/simple\.wasm$/), + platform: 'native', + }), + ]), + ); + }, +); diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index 36c88105a283..f5d4c087eeab 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -153,9 +153,13 @@ function getBundleKeysForAllFramesWithFilenames( return frames .filter((frame, index) => { - // Exclude frames without a filename or without lineno and colno, - // since these are likely native code or built-ins - if (!frame.filename || (frame.lineno == null && frame.colno == null)) { + // Exclude frames without a filename + if (!frame.filename) { + return false; + } + // Exclude frames without location info, since these are likely native code or built-ins. + // JS frames have lineno/colno, WASM frames have instruction_addr instead. + if (frame.lineno == null && frame.colno == null && frame.instruction_addr == null) { return false; } // Optionally ignore Sentry internal frames diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 5aa3888f1e4c..84076285fcdd 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -5,7 +5,16 @@ import { getImage, getImages } from './registry'; const INTEGRATION_NAME = 'Wasm'; -const _wasmIntegration = (() => { +interface WasmIntegrationOptions { + /** + * Key to identify this application for third-party error filtering. + * This key should match one of the keys provided to the `filterKeys` option + * of the `thirdPartyErrorFilterIntegration`. + */ + applicationKey?: string; +} + +const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { @@ -18,7 +27,7 @@ const _wasmIntegration = (() => { event.exception.values.forEach(exception => { if (exception.stacktrace?.frames) { hasAtLeastOneWasmFrameWithImage = - hasAtLeastOneWasmFrameWithImage || patchFrames(exception.stacktrace.frames); + hasAtLeastOneWasmFrameWithImage || patchFrames(exception.stacktrace.frames, options.applicationKey); } }); } @@ -37,13 +46,17 @@ export const wasmIntegration = defineIntegration(_wasmIntegration); const PARSER_REGEX = /^(.*?):wasm-function\[\d+\]:(0x[a-fA-F0-9]+)$/; +// We use the same prefix as bundler plugins so that thirdPartyErrorFilterIntegration +// recognizes WASM frames as first-party code without needing modifications. +const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; + /** * Patches a list of stackframes with wasm data needed for server-side symbolication * if applicable. Returns true if the provided list of stack frames had at least one * matching registered image. */ // Only exported for tests -export function patchFrames(frames: Array): boolean { +export function patchFrames(frames: Array, applicationKey?: string): boolean { let hasAtLeastOneWasmFrameWithImage = false; frames.forEach(frame => { if (!frame.filename) { @@ -71,6 +84,13 @@ export function patchFrames(frames: Array): boolean { frame.filename = match[1]; frame.platform = 'native'; + if (applicationKey) { + frame.module_metadata = { + ...frame.module_metadata, + [`${BUNDLER_PLUGIN_APP_KEY_PREFIX}${applicationKey}`]: true, + }; + } + if (index >= 0) { frame.addr_mode = `rel:${index}`; hasAtLeastOneWasmFrameWithImage = true; diff --git a/packages/wasm/test/stacktrace-parsing.test.ts b/packages/wasm/test/stacktrace-parsing.test.ts index f1f03c247fa8..658d02847305 100644 --- a/packages/wasm/test/stacktrace-parsing.test.ts +++ b/packages/wasm/test/stacktrace-parsing.test.ts @@ -1,7 +1,67 @@ +import type { StackFrame } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { patchFrames } from '../src/index'; describe('patchFrames()', () => { + it('should add module_metadata with applicationKey when provided', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.js', + function: 'run', + in_app: true, + }, + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + }, + ]; + + patchFrames(frames, 'my-app'); + + // Non-WASM frame should not have module_metadata + expect(frames[0]?.module_metadata).toBeUndefined(); + + // WASM frame should have module_metadata with the application key + expect(frames[1]?.module_metadata).toEqual({ + '_sentryBundlerPluginAppKey:my-app': true, + }); + }); + + it('should preserve existing module_metadata when adding applicationKey', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + module_metadata: { + existingKey: 'existingValue', + }, + }, + ]; + + patchFrames(frames, 'my-app'); + + expect(frames[0]?.module_metadata).toEqual({ + existingKey: 'existingValue', + '_sentryBundlerPluginAppKey:my-app': true, + }); + }); + + it('should not add module_metadata when applicationKey is not provided', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + }, + ]; + + patchFrames(frames); + + expect(frames[0]?.module_metadata).toBeUndefined(); + }); + it('should correctly extract instruction addresses', () => { const frames = [ {