diff --git a/.size-limit.js b/.size-limit.js index 6075311aaa01..7666355c9036 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '26 KB', + limit: '27 KB', }, { name: '@sentry/browser - with treeshaking flags', @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '44 KB', + limit: '45 KB', }, { name: '@sentry/browser (incl. Tracing + Span Streaming)', @@ -117,7 +117,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '36 KB', + limit: '37 KB', }, { name: '@sentry/browser (incl. Metrics)', @@ -178,7 +178,7 @@ module.exports = [ path: 'packages/svelte/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '26 KB', + limit: '27 KB', }, // Browser CDN bundles { @@ -227,7 +227,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '89 KB', + limit: '90 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', @@ -241,14 +241,14 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '84 KB', + limit: '85 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '138 KB', + limit: '139 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -262,35 +262,35 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '141.5 KB', + limit: '142 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '212 KB', + limit: '213 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '255.5 KB', + limit: '256 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '259 KB', + limit: '260 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '269 KB', + limit: '270 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed', diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js new file mode 100644 index 000000000000..a6a4e1f4740b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + ignoreSpans: [{ attributes: { 'http.status_code': 200 } }], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js new file mode 100644 index 000000000000..741f4077d2ca --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js @@ -0,0 +1,11 @@ +// This segment span matches ignoreSpans via attributes — segment + child should be dropped +Sentry.startSpan({ name: 'health-check', attributes: { 'http.status_code': 200 } }, () => { + Sentry.startSpan({ name: 'child-of-ignored' }, () => {}); +}); + +setTimeout(() => { + // This segment span does NOT match — segment + child should be sent + Sentry.startSpan({ name: 'normal-segment', attributes: { 'http.status_code': 500 } }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); +}, 1000); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts new file mode 100644 index 000000000000..903e2d4e9e2b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, +} from '../../../../utils/helpers'; +import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('attribute-matching ignoreSpans drops the trace', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + observeStreamedSpan(page, span => { + if (span.name === 'health-check' || span.name === 'child-of-ignored') { + throw new Error('Ignored span found'); + } + return false; + }); + + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'normal-segment')); + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(url); + + expect((await spansPromise)?.length).toBe(2); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport.discarded_events).toEqual([{ category: 'span', quantity: 2, reason: 'ignored' }]); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js index 645668376b36..0878cd4e9ad6 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js @@ -1,10 +1,10 @@ -// This segment span matches ignoreSpans — should NOT produce a transaction +// This segment span matches ignoreSpans — segment + child should be dropped Sentry.startSpan({ name: 'ignore-segment' }, () => { Sentry.startSpan({ name: 'child-of-ignored-segment' }, () => {}); }); setTimeout(() => { - // This segment span does NOT match — should produce a transaction + // This segment span does NOT match — segment + child should be sent Sentry.startSpan({ name: 'normal-segment' }, () => { Sentry.startSpan({ name: 'child-span' }, () => {}); }); 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..1ebfeab737d9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts @@ -0,0 +1,29 @@ +import { 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 }) => { + // Positive anchor: the navigation transaction we know the framework emits + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + // Negative: throw if a server txn for /__manifest? sneaks through + waitForTransaction(APP_NAME, async evt => { + if (evt.transaction?.match(/GET \/__manifest\?/)) { + throw new Error('Filtered manifest server transaction should not be sent'); + } + return false; + }); + + await page.goto('/performance'); // pageload + await page.waitForTimeout(1000); + await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation → fetches /__manifest? + + await navigationPromise; + await page.waitForTimeout(1000); // give late server txns a chance to flush + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 00c12db06855..62f94619e62f 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1600,7 +1600,13 @@ function processBeforeSend( const rootSpanJson = convertTransactionEventToSpanJson(processedEvent); // 1.1 If the root span should be ignored, drop the whole transaction - if (ignoreSpans?.length && shouldIgnoreSpan(rootSpanJson, ignoreSpans)) { + if ( + ignoreSpans?.length && + shouldIgnoreSpan( + { description: rootSpanJson.description, op: rootSpanJson.op, attributes: rootSpanJson.data }, + ignoreSpans, + ) + ) { // dropping the whole transaction! return null; } @@ -1624,7 +1630,10 @@ function processBeforeSend( for (const span of initialSpans) { // 2.a If the child span should be ignored, reparent it to the root span - if (ignoreSpans?.length && shouldIgnoreSpan(span, ignoreSpans)) { + if ( + ignoreSpans?.length && + shouldIgnoreSpan({ description: span.description, op: span.op, attributes: span.data }, ignoreSpans) + ) { reparentChildSpans(initialSpans, span); continue; } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index dd91d077f45c..6b7b251c542c 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -142,7 +142,10 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {}; const filteredSpans = ignoreSpans?.length - ? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans)) + ? spans.filter(span => { + const json = spanToJSON(span); + return !shouldIgnoreSpan({ description: json.description, op: json.op, attributes: json.data }, ignoreSpans); + }) : spans; const droppedSpans = spans.length - filteredSpans.length; diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 53848a9c9191..884a8bb05497 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -179,7 +179,13 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // Ignored spans will get dropped later (in the client) but since we already adjust // the idle span end timestamp here, we can already take to-be-ignored spans out of // the calculation here. - if (ignoreSpans && shouldIgnoreSpan(currentSpanJson, ignoreSpans)) { + if ( + ignoreSpans && + shouldIgnoreSpan( + { description: currentSpanJson.description, op: currentSpanJson.op, attributes: currentSpanJson.data }, + ignoreSpans, + ) + ) { return acc; } return acc ? Math.max(acc, currentSpanJson.timestamp) : currentSpanJson.timestamp; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 08411722cedf..45379866d56e 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -610,6 +610,7 @@ function _shouldIgnoreStreamedSpan(client: Client | undefined, spanArguments: Se { description: spanArguments.name || '', op: spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] || spanArguments.op, + attributes: spanArguments.attributes, }, ignoreSpans, ); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 4f3df1f6365a..a1fc1e074a75 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -100,9 +100,17 @@ export interface ServerRuntimeOptions { onFatalError?(this: void, error: Error): void; } +/** + * Allowed attribute value matchers in `ignoreSpans` filters. + * String span attributes use pattern matching (substring or RegExp). + * Non-string attribute values match by strict equality (arrays element-wise). + */ +export type IgnoreSpanAttributeValue = string | boolean | number | string[] | boolean[] | number[] | RegExp; + /** * A filter object for ignoring spans. - * At least one of the properties (`op` or `name`) must be set. + * At least one of the properties (`name`, `op`, or `attributes`) must be set. + * If multiple are set, all must match for the span to be ignored. */ type IgnoreSpanFilter = | { @@ -114,6 +122,12 @@ type IgnoreSpanFilter = * Spans with an op matching this pattern will be ignored. */ op?: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes?: Record; } | { /** @@ -124,6 +138,28 @@ type IgnoreSpanFilter = * Spans with an op matching this pattern will be ignored. */ op: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes?: Record; + } + | { + /** + * Spans with a name matching this pattern will be ignored. + */ + name?: string | RegExp; + /** + * Spans with an op matching this pattern will be ignored. + */ + op?: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes: Record; }; export interface ClientOptions { @@ -326,7 +362,8 @@ export interface ClientOptions): void * Check if a span should be ignored based on the ignoreSpans configuration. */ export function shouldIgnoreSpan( - span: Pick, + span: Pick & { attributes?: Record }, ignoreSpans: Required['ignoreSpans'], ): boolean { - if (!ignoreSpans?.length || !span.description) { + if (!ignoreSpans?.length) { return false; } for (const pattern of ignoreSpans) { if (isStringOrRegExp(pattern)) { - if (isMatchingPattern(span.description, pattern)) { + if (span.description && isMatchingPattern(span.description, pattern)) { DEBUG_BUILD && logIgnoredSpan(span); return true; } continue; } - if (!pattern.name && !pattern.op) { + if (!pattern.name && !pattern.op && !pattern.attributes) { continue; } - const nameMatches = pattern.name ? isMatchingPattern(span.description, pattern.name) : true; + const nameMatches = pattern.name ? span.description && isMatchingPattern(span.description, pattern.name) : true; const opMatches = pattern.op ? span.op && isMatchingPattern(span.op, pattern.op) : true; + const attrsMatch = pattern.attributes + ? Object.entries(pattern.attributes).every(([key, valuePattern]) => + _matchesAttributeValue(span.attributes?.[key], valuePattern), + ) + : true; // This check here is only correct because we can guarantee that we ran `isMatchingPattern` - // for at least one of `nameMatches` and `opMatches`. So in contrary to how this looks, - // not both op and name actually have to match. This is the most efficient way to check - // for all combinations of name and op patterns. - if (nameMatches && opMatches) { + // for at least one of `nameMatches`, `opMatches`, or `attrsMatch`. So in contrary to how this looks, + // not all of op, name, and attributes actually have to match. This is the most efficient way to check + // for all combinations of name, op, and attribute patterns. + if (nameMatches && opMatches && attrsMatch) { DEBUG_BUILD && logIgnoredSpan(span); return true; } @@ -48,6 +53,19 @@ export function shouldIgnoreSpan( return false; } +function _matchesAttributeValue(actual: unknown, pat: IgnoreSpanAttributeValue): boolean { + // String values support pattern matching + if (typeof actual === 'string' && (typeof pat === 'string' || pat instanceof RegExp)) { + return isMatchingPattern(actual, pat); + } + // Arrays: element-wise strict equality + if (Array.isArray(actual) && Array.isArray(pat)) { + return actual.length === pat.length && actual.every((v, i) => v === pat[i]); + } + // Primitives: strict equality + return actual === pat; +} + /** * Takes a list of spans, and a span that was dropped, and re-parents the child spans of the dropped span to the parent of the dropped span, if possible. * This mutates the spans array in place! diff --git a/packages/core/test/lib/utils/should-ignore-span.test.ts b/packages/core/test/lib/utils/should-ignore-span.test.ts index a9aa3953b458..82e67461cf77 100644 --- a/packages/core/test/lib/utils/should-ignore-span.test.ts +++ b/packages/core/test/lib/utils/should-ignore-span.test.ts @@ -103,6 +103,56 @@ describe('shouldIgnoreSpan', () => { expect(shouldIgnoreSpan({ description: 'GET /health', op: 'http.server' }, [{ op: 'http.server' }])).toBe(true); }); + describe('attribute matching', () => { + it.each([ + // strings: pattern matching (substring + regex) + ['GET', 'GE', true], + ['GET', 'POST', false], + ['GET', /^GET$/, true], + ['GET', /^POST$/, false], + // numbers: strict equality + [200, 200, true], + [404, 200, false], + // booleans: strict equality + [true, true, true], + [true, false, false], + // no type coercion across primitive types + [true, 'true', false], + // arrays: element-wise strict equality (one positive per element type, plus mismatch shapes) + [['a', 'b'], ['a', 'b'], true], + [['a', 'b'], ['a', 'c'], false], + [['a', 'b'], ['a'], false], + [[1, 2], [1, 2], true], + [[true, false], [true, false], true], + ])('matches attribute value %j against pattern %j → %s', (actual, pattern, expected) => { + const span = { description: 'span', op: 'op', attributes: { x: actual } }; + expect(shouldIgnoreSpan(span, [{ attributes: { x: pattern } }])).toBe(expected); + }); + + it('does not match when the attribute key is absent on the span', () => { + const span = { description: 'span', op: 'op', attributes: {} }; + expect(shouldIgnoreSpan(span, [{ attributes: { 'missing.key': 'x' } }])).toBe(false); + }); + + it('requires every attribute entry to match', () => { + const span = { description: 'span', op: 'op', attributes: { a: 1, b: 2 } }; + expect(shouldIgnoreSpan(span, [{ attributes: { a: 1, b: 2 } }])).toBe(true); + expect(shouldIgnoreSpan(span, [{ attributes: { a: 1, b: 3 } }])).toBe(false); + }); + + it('requires both name and attributes to match', () => { + const span = { description: 'GET /healthz', op: 'http.server', attributes: { 'http.method': 'GET' } }; + expect(shouldIgnoreSpan(span, [{ name: /healthz?/, attributes: { 'http.method': 'GET' } }])).toBe(true); + expect(shouldIgnoreSpan(span, [{ name: /healthz?/, attributes: { 'http.method': 'POST' } }])).toBe(false); + expect(shouldIgnoreSpan(span, [{ name: /other/, attributes: { 'http.method': 'GET' } }])).toBe(false); + }); + + it('still matches an attribute-only filter on a span without a description', () => { + const span = { description: undefined as unknown as string, op: undefined, attributes: { foo: 'bar' } }; + expect(shouldIgnoreSpan(span, [{ attributes: { foo: 'bar' } }])).toBe(true); + }); + }); + it('emits a debug log when a span is ignored', () => { const debugLogSpy = vi.spyOn(debug, 'log'); const span = { description: 'testDescription', op: 'testOp' }; diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 1e65e9d15d14..235ff3247f5d 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -96,7 +96,11 @@ export class SentrySampler implements Sampler { const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind); if ( shouldIgnoreSpan( - { description: inferredChildName, op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp }, + { + description: inferredChildName, + op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp, + attributes: spanAttributes, + }, ignoreSpans, ) ) { @@ -144,7 +148,11 @@ export class SentrySampler implements Sampler { this._isSpanStreaming && ignoreSpans?.length && shouldIgnoreSpan( - { description: inferredSpanName, op: mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? op }, + { + description: inferredSpanName, + op: mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? op, + attributes: mergedAttributes, + }, ignoreSpans, ) ) { diff --git a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts deleted file mode 100644 index e4471167f7ce..000000000000 --- a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type Client, debug, defineIntegration, type Event, type EventHint } 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\//, /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), -); diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index 8c3954e4a418..9d9c8817c360 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -3,7 +3,6 @@ import { applySdkMetadata, debug, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; -import { lowQualityTransactionsFilterIntegration } from './integration/lowQualityTransactionsFilterIntegration'; import { reactRouterServerIntegration } from './integration/reactRouterServer'; /** @@ -11,13 +10,16 @@ import { reactRouterServerIntegration } from './integration/reactRouterServer'; * @param options The options for the SDK. */ export function getDefaultReactRouterServerIntegrations(options: NodeOptions): Integration[] { - return [ - ...getNodeDefaultIntegrations(options), - lowQualityTransactionsFilterIntegration(options), - reactRouterServerIntegration(), - ]; + return [...getNodeDefaultIntegrations(options), reactRouterServerIntegration()]; } +const LOW_QUALITY_TRANSACTIONS_REGEXES = [ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + /GET \/__manifest\?/, +]; + /** * Initializes the server side of the React Router SDK */ @@ -27,6 +29,8 @@ export function init(options: NodeOptions): NodeClient | undefined { defaultIntegrations: getDefaultReactRouterServerIntegrations(options), }; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...LOW_QUALITY_TRANSACTIONS_REGEXES]; + DEBUG_BUILD && debug.log('Initializing SDK...'); applySdkMetadata(opts, 'react-router', ['react-router', 'node']); diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts deleted file mode 100644 index 7edd75c9e996..000000000000 --- a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 { 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(); - }); - - 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); - - expect(result).toEqual(event); - }); - }); - - 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); - }); - }); -}); diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index 6e1879f8e24b..743b36d4108e 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -3,7 +3,6 @@ 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/integration/lowQualityTransactionsFilterIntegration'; import { init as reactRouterInit } from '../../src/server/sdk'; const nodeInit = vi.spyOn(SentryNode, 'init'); @@ -48,28 +47,29 @@ describe('React Router server SDK', () => { 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]; + it('configures ignoreSpans to drop low-quality transactions', () => { + reactRouterInit({}); - expect(initOptions).toBeDefined(); + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + /GET \/__manifest\?/, + ]), + }), + ); + }); - const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; - expect(Array.isArray(defaultIntegrations)).toBe(true); + it('preserves user-provided ignoreSpans entries', () => { + reactRouterInit({ ignoreSpans: [/keep-me/] }); - const filterIntegration = defaultIntegrations.find( - integration => integration.name === 'LowQualityTransactionsFilter', + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([/keep-me/]), + }), ); - - expect(filterIntegration).toBeDefined(); }); it('adds reactRouterServer integration by default', () => {