Skip to content

Commit 6c2e062

Browse files
authored
feat(react-router): Drop low-quality transactions via ignoreSpans (#20514)
Migrates the React Router low-quality transactions filter from a dedicated event-processor integration (`lowQualityTransactionsFilterIntegration`) to `ignoreSpans` so it also works in the streaming path. Adds some unit tests plus a new e2e test in `react-router-7-framework` that asserts no server transaction is sent for filtered `/__manifest?` requests during client-side navigation (no e2e covered this filter before). Closes #20362
1 parent df62ed9 commit 6c2e062

5 files changed

Lines changed: 114 additions & 90 deletions

File tree

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes
22

33
export default [
44
index('routes/home.tsx'),
5+
route('__sentry-flush', 'routes/sentry-flush.tsx'),
56
...prefix('errors', [
67
route('client', 'routes/errors/client.tsx'),
78
route('client/:client-param', 'routes/errors/client-param.tsx'),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as Sentry from '@sentry/react-router';
2+
3+
export async function loader() {
4+
await Sentry.flush(2000);
5+
return new Response(null, { status: 204 });
6+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { APP_NAME } from '../constants';
4+
5+
test.describe('low-quality transaction filter', () => {
6+
test('does not send a server transaction for /__manifest? requests', async ({ page }) => {
7+
const serverTxns: Array<{ contexts?: { trace?: { data?: Record<string, unknown> } } }> = [];
8+
9+
const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => {
10+
return (
11+
transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation'
12+
);
13+
});
14+
15+
waitForTransaction(APP_NAME, async evt => {
16+
serverTxns.push(evt);
17+
return false;
18+
});
19+
20+
await page.goto('/performance');
21+
await page.waitForTimeout(1000);
22+
await page.getByRole('link', { name: 'SSR Page' }).click();
23+
24+
await navigationPromise;
25+
26+
// Force the server to flush any in-flight transactions before we assert
27+
await page.evaluate(() => fetch('/__sentry-flush'));
28+
29+
const targetIsManifest = (t: (typeof serverTxns)[number]) =>
30+
typeof t.contexts?.trace?.data?.['http.target'] === 'string' &&
31+
(t.contexts.trace.data['http.target'] as string).includes('/__manifest');
32+
expect(serverTxns.some(targetIsManifest)).toBe(false);
33+
});
34+
});
Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,27 @@
1-
import { type Client, debug, defineIntegration, type Event, type EventHint } from '@sentry/core';
1+
import type { IntegrationFn } from '@sentry/core';
2+
import { defineIntegration } from '@sentry/core';
23
import type { NodeOptions } from '@sentry/node';
34

5+
const LOW_QUALITY_TRANSACTIONS_FILTERS = [
6+
/GET \/node_modules\//,
7+
/GET \/favicon\.ico/,
8+
/GET \/@id\//,
9+
// The span description for the `__manifest` endpoint is `GET *` (`http.route` resolves to `*`).
10+
// Filter by `http.target` instead, which carries the raw request path.
11+
{ attributes: { 'http.target': /\/__manifest/ } },
12+
];
13+
14+
// TODO(v11): Remove the `_options` parameter (unused and only kept for back-compat with the previous signature)
15+
const _lowQualityTransactionsFilterIntegration = ((_options?: NodeOptions) => ({
16+
name: 'LowQualityTransactionsFilter',
17+
beforeSetup(client) {
18+
const opts = client.getOptions();
19+
opts.ignoreSpans = [...(opts.ignoreSpans || []), ...LOW_QUALITY_TRANSACTIONS_FILTERS];
20+
},
21+
})) satisfies IntegrationFn;
22+
423
/**
5-
* Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/
6-
*
24+
* Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/, __manifest.
25+
* Adds entries to `ignoreSpans` so the filter applies in both static and streaming trace lifecycles.
726
*/
8-
9-
function _lowQualityTransactionsFilterIntegration(options: NodeOptions): {
10-
name: string;
11-
processEvent: (event: Event, hint: EventHint, client: Client) => Event | null;
12-
} {
13-
const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//, /GET \/__manifest\?/];
14-
15-
return {
16-
name: 'LowQualityTransactionsFilter',
17-
18-
processEvent(event: Event, _hint: EventHint, _client: Client): Event | null {
19-
if (event.type !== 'transaction' || !event.transaction) {
20-
return event;
21-
}
22-
23-
const transaction = event.transaction;
24-
25-
if (matchedRegexes.some(regex => transaction.match(regex))) {
26-
options.debug && debug.log('[ReactRouter] Filtered node_modules transaction:', event.transaction);
27-
return null;
28-
}
29-
30-
return event;
31-
},
32-
};
33-
}
34-
35-
export const lowQualityTransactionsFilterIntegration = defineIntegration((options: NodeOptions) =>
36-
_lowQualityTransactionsFilterIntegration(options),
37-
);
27+
export const lowQualityTransactionsFilterIntegration = defineIntegration(_lowQualityTransactionsFilterIntegration);
Lines changed: 50 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,60 @@
1-
import type { Event, EventType } from '@sentry/core';
2-
import * as SentryCore from '@sentry/core';
3-
import * as SentryNode from '@sentry/node';
4-
import { afterEach, describe, expect, it, vi } from 'vitest';
1+
import type { Client, ClientOptions } from '@sentry/core';
2+
import { shouldIgnoreSpan } from '@sentry/core';
3+
import { describe, expect, it } from 'vitest';
54
import { lowQualityTransactionsFilterIntegration } from '../../src/server/integration/lowQualityTransactionsFilterIntegration';
65

7-
const debugLoggerLogSpy = vi.spyOn(SentryCore.debug, 'log').mockImplementation(() => {});
8-
9-
describe('Low Quality Transactions Filter Integration', () => {
10-
afterEach(() => {
11-
vi.clearAllMocks();
12-
SentryNode.getGlobalScope().clear();
6+
function makeMockClient(initial: Partial<ClientOptions> = {}): Client {
7+
const options = { ...initial } as ClientOptions;
8+
return { getOptions: () => options } as Client;
9+
}
10+
11+
function setupIntegrationAndGetIgnoreSpans(initial: Partial<ClientOptions> = {}) {
12+
const integration = lowQualityTransactionsFilterIntegration({});
13+
const client = makeMockClient(initial);
14+
integration.beforeSetup!(client);
15+
return client.getOptions().ignoreSpans!;
16+
}
17+
18+
describe('lowQualityTransactionsFilterIntegration', () => {
19+
it('appends the low-quality filters to ignoreSpans', () => {
20+
expect(setupIntegrationAndGetIgnoreSpans()).toEqual([
21+
/GET \/node_modules\//,
22+
/GET \/favicon\.ico/,
23+
/GET \/@id\//,
24+
{ attributes: { 'http.target': /\/__manifest/ } },
25+
]);
1326
});
1427

15-
describe('integration functionality', () => {
16-
describe('filters out low quality transactions', () => {
17-
it.each([
18-
['node_modules requests', 'GET /node_modules/some-package/index.js'],
19-
['favicon.ico requests', 'GET /favicon.ico'],
20-
['@id/ requests', 'GET /@id/some-id'],
21-
['manifest requests', 'GET /__manifest?p=%2Fperformance%2Fserver-action'],
22-
])('%s', (description, transaction) => {
23-
const integration = lowQualityTransactionsFilterIntegration({ debug: true });
24-
const event = {
25-
type: 'transaction' as EventType,
26-
transaction,
27-
} as Event;
28-
29-
const result = integration.processEvent!(event, {}, {} as SentryCore.Client);
30-
31-
expect(result).toBeNull();
32-
33-
expect(debugLoggerLogSpy).toHaveBeenCalledWith('[ReactRouter] Filtered node_modules transaction:', transaction);
34-
});
35-
});
36-
37-
describe('allows high quality transactions', () => {
38-
it.each([
39-
['normal page requests', 'GET /api/users'],
40-
['API endpoints', 'POST /data'],
41-
['app routes', 'GET /projects/123'],
42-
])('%s', (description, transaction) => {
43-
const integration = lowQualityTransactionsFilterIntegration({});
44-
const event = {
45-
type: 'transaction' as EventType,
46-
transaction,
47-
} as Event;
48-
49-
const result = integration.processEvent!(event, {}, {} as SentryCore.Client);
28+
it('preserves user-provided ignoreSpans entries', () => {
29+
expect(setupIntegrationAndGetIgnoreSpans({ ignoreSpans: [/keep-me/] })).toEqual([
30+
/keep-me/,
31+
/GET \/node_modules\//,
32+
/GET \/favicon\.ico/,
33+
/GET \/@id\//,
34+
{ attributes: { 'http.target': /\/__manifest/ } },
35+
]);
36+
});
5037

51-
expect(result).toEqual(event);
52-
});
38+
describe('drops low-quality transactions', () => {
39+
it.each([
40+
['node_modules requests', { description: 'GET /node_modules/some-package/index.js' }],
41+
['favicon.ico requests', { description: 'GET /favicon.ico' }],
42+
['@id/ requests', { description: 'GET /@id/some-id' }],
43+
['manifest requests', { description: 'GET *', attributes: { 'http.target': '/__manifest?paths=foo' } }],
44+
])('%s', (_label, span) => {
45+
const ignoreSpans = setupIntegrationAndGetIgnoreSpans();
46+
expect(shouldIgnoreSpan({ op: 'http.server', ...span }, ignoreSpans)).toBe(true);
5347
});
48+
});
5449

55-
it('does not affect non-transaction events', () => {
56-
const integration = lowQualityTransactionsFilterIntegration({});
57-
const event = {
58-
type: 'error' as EventType,
59-
transaction: 'GET /node_modules/some-package/index.js',
60-
} as Event;
61-
62-
const result = integration.processEvent!(event, {}, {} as SentryCore.Client);
63-
64-
expect(result).toEqual(event);
50+
describe('keeps high-quality transactions', () => {
51+
it.each([
52+
['normal page requests', 'GET /api/users'],
53+
['API endpoints', 'POST /data'],
54+
['app routes', 'GET /projects/123'],
55+
])('%s', (_label, name) => {
56+
const ignoreSpans = setupIntegrationAndGetIgnoreSpans();
57+
expect(shouldIgnoreSpan({ description: name, op: 'http.server' }, ignoreSpans)).toBe(false);
6558
});
6659
});
6760
});

0 commit comments

Comments
 (0)