Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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://[email protected]/1337',
integrations: [
wasmIntegration({ applicationKey: 'wasm-test-app' }),
thirdPartyErrorFilterIntegration({
behaviour: 'apply-tag-if-contains-third-party-frames',
filterKeys: ['wasm-test-app'],
}),
],
});
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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',
}),
]),
);
},
);
10 changes: 7 additions & 3 deletions packages/core/src/integrations/third-party-errors-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions packages/wasm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
}
});
}
Expand All @@ -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<StackFrame>): boolean {
export function patchFrames(frames: Array<StackFrame>, applicationKey?: string): boolean {
let hasAtLeastOneWasmFrameWithImage = false;
frames.forEach(frame => {
if (!frame.filename) {
Expand Down Expand Up @@ -71,6 +84,13 @@ export function patchFrames(frames: Array<StackFrame>): 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;
Expand Down
60 changes: 60 additions & 0 deletions packages/wasm/test/stacktrace-parsing.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
{
Expand Down
Loading