diff --git a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts new file mode 100644 index 000000000000..705359eab62c --- /dev/null +++ b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts @@ -0,0 +1,37 @@ +import { type Client, type Event, type EventHint, defineIntegration, logger } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node'; + +/** + * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/ + * + */ + +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\//]; + + 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 && logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); + return null; + } + + return event; + }, + }; +} + +export const lowQualityTransactionsFilterIntegration = defineIntegration((options: NodeOptions) => + _lowQualityTransactionsFilterIntegration(options), +); diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index d1e6b32b1d96..c980078ac7b5 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,7 +1,13 @@ +import type { Integration } from '@sentry/core'; import { applySdkMetadata, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { init as initNodeSdk } from '@sentry/node'; +import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; +import { lowQualityTransactionsFilterIntegration } from './lowQualityTransactionsFilterIntegration'; + +function getDefaultIntegrations(options: NodeOptions): Integration[] { + return [...getNodeDefaultIntegrations(options), lowQualityTransactionsFilterIntegration(options)]; +} /** * Initializes the server side of the React Router SDK @@ -9,6 +15,7 @@ import { DEBUG_BUILD } from '../common/debug-build'; export function init(options: NodeOptions): NodeClient | undefined { const opts = { ...options, + defaultIntegrations: getDefaultIntegrations(options), }; DEBUG_BUILD && logger.log('Initializing SDK...'); @@ -20,5 +27,6 @@ export function init(options: NodeOptions): NodeClient | undefined { setTag('runtime', 'node'); DEBUG_BUILD && logger.log('SDK successfully initialized'); + return client; } diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts new file mode 100644 index 000000000000..58ddf3e215d6 --- /dev/null +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -0,0 +1,66 @@ +import type { Event, EventType, Integration } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import * as SentryNode from '@sentry/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { lowQualityTransactionsFilterIntegration } from '../../src/server/lowQualityTransactionsFilterIntegration'; + +const loggerLog = vi.spyOn(SentryCore.logger, 'log').mockImplementation(() => {}); + +describe('Low Quality Transactions Filter Integration', () => { + afterEach(() => { + vi.clearAllMocks(); + SentryNode.getGlobalScope().clear(); + }); + + 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'], + ])('%s', (description, transaction) => { + const integration = lowQualityTransactionsFilterIntegration({ debug: true }) as Integration; + const event = { + type: 'transaction' as EventType, + transaction, + } as Event; + + const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + + expect(result).toBeNull(); + + expect(loggerLog).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({}) as Integration; + const event = { + type: 'transaction' as EventType, + transaction, + } as Event; + + const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + + expect(result).toEqual(event); + }); + }); + + it('does not affect non-transaction events', () => { + const integration = lowQualityTransactionsFilterIntegration({}) as Integration; + 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); + }); + }); +}); diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index 55c12935fe66..57b51d16c042 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -1,6 +1,9 @@ +import type { Integration } from '@sentry/core'; +import type { NodeClient } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { SDK_VERSION } from '@sentry/node'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as LowQualityModule from '../../src/server/lowQualityTransactionsFilterIntegration'; import { init as reactRouterInit } from '../../src/server/sdk'; const nodeInit = vi.spyOn(SentryNode, 'init'); @@ -39,7 +42,34 @@ describe('React Router server SDK', () => { }); it('returns client from init', () => { - expect(reactRouterInit({})).not.toBeUndefined(); + const client = reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }) as NodeClient; + expect(client).not.toBeUndefined(); + }); + + it('adds the low quality transactions filter integration by default', () => { + const filterSpy = vi.spyOn(LowQualityModule, 'lowQualityTransactionsFilterIntegration'); + + reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(filterSpy).toHaveBeenCalled(); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const initOptions = nodeInit.mock.calls[0]?.[0]; + + expect(initOptions).toBeDefined(); + + const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; + expect(Array.isArray(defaultIntegrations)).toBe(true); + + const filterIntegration = defaultIntegrations.find( + integration => integration.name === 'LowQualityTransactionsFilter', + ); + + expect(filterIntegration).toBeDefined(); }); }); });