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/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 25dbb9416fe6..727350f2b046 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -1,5 +1,5 @@ import { applySdkMetadata } from '@sentry/core'; -import type { Event, NodeClient, NodeOptions } from '@sentry/node'; +import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; /** @@ -13,28 +13,14 @@ export function init(options: NodeOptions): NodeClient | undefined { applySdkMetadata(opts, 'astro', ['astro', 'node']); - const client = initNodeSdk(opts); + opts.ignoreSpans = [ + ...(opts.ignoreSpans || []), + // For http.server spans that did not go though the astro middleware, + // we want to drop them + // this is the case with http.server spans of prerendered pages + // we do not care about those, as they are effectively static + { op: 'http.server', attributes: { 'sentry.origin': 'auto.http.otel.http' } }, + ]; - client?.addEventProcessor( - Object.assign( - (event: Event) => { - // For http.server spans that did not go though the astro middleware, - // we want to drop them - // this is the case with http.server spans of prerendered pages - // we do not care about those, as they are effectively static - if ( - event.type === 'transaction' && - event.contexts?.trace?.op === 'http.server' && - event.contexts?.trace?.origin === 'auto.http.otel.http' - ) { - return null; - } - - return event; - }, - { id: 'AstroHttpEventProcessor' }, - ), - ); - - return client; + return initNodeSdk(opts); } diff --git a/packages/astro/test/server/sdk.test.ts b/packages/astro/test/server/sdk.test.ts index 1d915152fdcc..19c80f4f46f0 100644 --- a/packages/astro/test/server/sdk.test.ts +++ b/packages/astro/test/server/sdk.test.ts @@ -41,5 +41,27 @@ describe('Sentry server SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('configures ignoreSpans to drop prerendered http.server spans', () => { + init({}); + + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([ + { op: 'http.server', attributes: { 'sentry.origin': 'auto.http.otel.http' } }, + ]), + }), + ); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: [/keep-me/] }); + + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([/keep-me/]), + }), + ); + }); }); }); 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, ) ) {