diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index 731081b54f52..1c5bb472d162 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes export default [ index('routes/home.tsx'), + route('__sentry-flush', 'routes/sentry-flush.tsx'), ...prefix('errors', [ route('client', 'routes/errors/client.tsx'), route('client/:client-param', 'routes/errors/client-param.tsx'), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx new file mode 100644 index 000000000000..c72024185046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/react-router'; + +export async function loader() { + await Sentry.flush(2000); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts new file mode 100644 index 000000000000..0e5351a5704f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('low-quality transaction filter', () => { + test('does not send a server transaction for /__manifest? requests', async ({ page }) => { + const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = []; + + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + waitForTransaction(APP_NAME, async evt => { + serverTxns.push(evt); + return false; + }); + + await page.goto('/performance'); + await page.waitForTimeout(1000); + await page.getByRole('link', { name: 'SSR Page' }).click(); + + await navigationPromise; + + // Force the server to flush any in-flight transactions before we assert + await page.evaluate(() => fetch('/__sentry-flush')); + + const targetIsManifest = (t: (typeof serverTxns)[number]) => + typeof t.contexts?.trace?.data?.['http.target'] === 'string' && + (t.contexts.trace.data['http.target'] as string).includes('/__manifest'); + expect(serverTxns.some(targetIsManifest)).toBe(false); + }); +}); diff --git a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts index e4471167f7ce..b17627f4bb85 100644 --- a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts +++ b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts @@ -1,37 +1,27 @@ -import { type Client, debug, defineIntegration, type Event, type EventHint } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import type { NodeOptions } from '@sentry/node'; +const LOW_QUALITY_TRANSACTIONS_FILTERS = [ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + // The span description for the `__manifest` endpoint is `GET *` (`http.route` resolves to `*`). + // Filter by `http.target` instead, which carries the raw request path. + { attributes: { 'http.target': /\/__manifest/ } }, +]; + +// TODO(v11): Remove the `_options` parameter (unused and only kept for back-compat with the previous signature) +const _lowQualityTransactionsFilterIntegration = ((_options?: NodeOptions) => ({ + name: 'LowQualityTransactionsFilter', + beforeSetup(client) { + const opts = client.getOptions(); + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...LOW_QUALITY_TRANSACTIONS_FILTERS]; + }, +})) satisfies IntegrationFn; + /** - * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/ - * + * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/, __manifest. + * Adds entries to `ignoreSpans` so the filter applies in both static and streaming trace lifecycles. */ - -function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { - name: string; - processEvent: (event: Event, hint: EventHint, client: Client) => Event | null; -} { - const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//, /GET \/__manifest\?/]; - - return { - name: 'LowQualityTransactionsFilter', - - processEvent(event: Event, _hint: EventHint, _client: Client): Event | null { - if (event.type !== 'transaction' || !event.transaction) { - return event; - } - - const transaction = event.transaction; - - if (matchedRegexes.some(regex => transaction.match(regex))) { - options.debug && debug.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); - return null; - } - - return event; - }, - }; -} - -export const lowQualityTransactionsFilterIntegration = defineIntegration((options: NodeOptions) => - _lowQualityTransactionsFilterIntegration(options), -); +export const lowQualityTransactionsFilterIntegration = defineIntegration(_lowQualityTransactionsFilterIntegration); diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts index 7edd75c9e996..b64b850c9b94 100644 --- a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -1,67 +1,60 @@ -import type { Event, EventType } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; -import * as SentryNode from '@sentry/node'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Client, ClientOptions } from '@sentry/core'; +import { shouldIgnoreSpan } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; import { lowQualityTransactionsFilterIntegration } from '../../src/server/integration/lowQualityTransactionsFilterIntegration'; -const debugLoggerLogSpy = vi.spyOn(SentryCore.debug, 'log').mockImplementation(() => {}); - -describe('Low Quality Transactions Filter Integration', () => { - afterEach(() => { - vi.clearAllMocks(); - SentryNode.getGlobalScope().clear(); +function makeMockClient(initial: Partial = {}): Client { + const options = { ...initial } as ClientOptions; + return { getOptions: () => options } as Client; +} + +function setupIntegrationAndGetIgnoreSpans(initial: Partial = {}) { + const integration = lowQualityTransactionsFilterIntegration({}); + const client = makeMockClient(initial); + integration.beforeSetup!(client); + return client.getOptions().ignoreSpans!; +} + +describe('lowQualityTransactionsFilterIntegration', () => { + it('appends the low-quality filters to ignoreSpans', () => { + expect(setupIntegrationAndGetIgnoreSpans()).toEqual([ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + { attributes: { 'http.target': /\/__manifest/ } }, + ]); }); - describe('integration functionality', () => { - describe('filters out low quality transactions', () => { - it.each([ - ['node_modules requests', 'GET /node_modules/some-package/index.js'], - ['favicon.ico requests', 'GET /favicon.ico'], - ['@id/ requests', 'GET /@id/some-id'], - ['manifest requests', 'GET /__manifest?p=%2Fperformance%2Fserver-action'], - ])('%s', (description, transaction) => { - const integration = lowQualityTransactionsFilterIntegration({ debug: true }); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); - - expect(result).toBeNull(); - - expect(debugLoggerLogSpy).toHaveBeenCalledWith('[ReactRouter] Filtered node_modules transaction:', transaction); - }); - }); - - describe('allows high quality transactions', () => { - it.each([ - ['normal page requests', 'GET /api/users'], - ['API endpoints', 'POST /data'], - ['app routes', 'GET /projects/123'], - ])('%s', (description, transaction) => { - const integration = lowQualityTransactionsFilterIntegration({}); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + it('preserves user-provided ignoreSpans entries', () => { + expect(setupIntegrationAndGetIgnoreSpans({ ignoreSpans: [/keep-me/] })).toEqual([ + /keep-me/, + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + { attributes: { 'http.target': /\/__manifest/ } }, + ]); + }); - expect(result).toEqual(event); - }); + describe('drops low-quality transactions', () => { + it.each([ + ['node_modules requests', { description: 'GET /node_modules/some-package/index.js' }], + ['favicon.ico requests', { description: 'GET /favicon.ico' }], + ['@id/ requests', { description: 'GET /@id/some-id' }], + ['manifest requests', { description: 'GET *', attributes: { 'http.target': '/__manifest?paths=foo' } }], + ])('%s', (_label, span) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ op: 'http.server', ...span }, ignoreSpans)).toBe(true); }); + }); - it('does not affect non-transaction events', () => { - const integration = lowQualityTransactionsFilterIntegration({}); - const event = { - type: 'error' as EventType, - transaction: 'GET /node_modules/some-package/index.js', - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); - - expect(result).toEqual(event); + describe('keeps high-quality transactions', () => { + it.each([ + ['normal page requests', 'GET /api/users'], + ['API endpoints', 'POST /data'], + ['app routes', 'GET /projects/123'], + ])('%s', (_label, name) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ description: name, op: 'http.server' }, ignoreSpans)).toBe(false); }); }); });