Skip to content

Commit 15a6a8b

Browse files
authored
feat(core): Support attribute matching in ignoreSpans (#20512)
Extends `ignoreSpans` with a new optional attributes field per the `ignoreSpans` [spec](https://develop.sentry.dev/sdk/telemetry/spans/filtering/#filter-with-ignorespans), letting users drop spans based on span attributes (in addition to name/description or op). String attribute values use pattern matching (substring / RegExp). Non-string values match by strict equality (arrays element-wise). Closes #20360
1 parent cdd8b08 commit 15a6a8b

16 files changed

Lines changed: 308 additions & 32 deletions

File tree

.size-limit.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module.exports = [
88
path: 'packages/browser/build/npm/esm/prod/index.js',
99
import: createImport('init'),
1010
gzip: true,
11-
limit: '26 KB',
11+
limit: '27 KB',
1212
disablePlugins: ['@size-limit/esbuild'],
1313
},
1414
{
@@ -40,7 +40,7 @@ module.exports = [
4040
path: 'packages/browser/build/npm/esm/prod/index.js',
4141
import: createImport('init', 'browserTracingIntegration'),
4242
gzip: true,
43-
limit: '44 KB',
43+
limit: '45 KB',
4444
disablePlugins: ['@size-limit/esbuild'],
4545
},
4646
{
@@ -128,7 +128,7 @@ module.exports = [
128128
path: 'packages/browser/build/npm/esm/prod/index.js',
129129
import: createImport('init', 'feedbackAsyncIntegration'),
130130
gzip: true,
131-
limit: '36 KB',
131+
limit: '37 KB',
132132
disablePlugins: ['@size-limit/esbuild'],
133133
},
134134
{
@@ -197,7 +197,7 @@ module.exports = [
197197
path: 'packages/svelte/build/esm/index.js',
198198
import: createImport('init'),
199199
gzip: true,
200-
limit: '26 KB',
200+
limit: '27 KB',
201201
disablePlugins: ['@size-limit/esbuild'],
202202
},
203203
// Browser CDN bundles
@@ -254,7 +254,7 @@ module.exports = [
254254
name: 'CDN Bundle (incl. Tracing, Replay, Feedback)',
255255
path: createCDNPath('bundle.tracing.replay.feedback.min.js'),
256256
gzip: true,
257-
limit: '89 KB',
257+
limit: '90 KB',
258258
disablePlugins: ['@size-limit/esbuild'],
259259
},
260260
{
@@ -270,15 +270,15 @@ module.exports = [
270270
path: createCDNPath('bundle.min.js'),
271271
gzip: false,
272272
brotli: false,
273-
limit: '84 KB',
273+
limit: '85 KB',
274274
disablePlugins: ['@size-limit/esbuild'],
275275
},
276276
{
277277
name: 'CDN Bundle (incl. Tracing) - uncompressed',
278278
path: createCDNPath('bundle.tracing.min.js'),
279279
gzip: false,
280280
brotli: false,
281-
limit: '138 KB',
281+
limit: '139 KB',
282282
disablePlugins: ['@size-limit/esbuild'],
283283
},
284284
{
@@ -294,39 +294,39 @@ module.exports = [
294294
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
295295
gzip: false,
296296
brotli: false,
297-
limit: '141.5 KB',
297+
limit: '142 KB',
298298
disablePlugins: ['@size-limit/esbuild'],
299299
},
300300
{
301301
name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed',
302302
path: createCDNPath('bundle.replay.logs.metrics.min.js'),
303303
gzip: false,
304304
brotli: false,
305-
limit: '212 KB',
305+
limit: '213 KB',
306306
disablePlugins: ['@size-limit/esbuild'],
307307
},
308308
{
309309
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
310310
path: createCDNPath('bundle.tracing.replay.min.js'),
311311
gzip: false,
312312
brotli: false,
313-
limit: '255.5 KB',
313+
limit: '256 KB',
314314
disablePlugins: ['@size-limit/esbuild'],
315315
},
316316
{
317317
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
318318
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
319319
gzip: false,
320320
brotli: false,
321-
limit: '259 KB',
321+
limit: '260 KB',
322322
disablePlugins: ['@size-limit/esbuild'],
323323
},
324324
{
325325
name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed',
326326
path: createCDNPath('bundle.tracing.replay.feedback.min.js'),
327327
gzip: false,
328328
brotli: false,
329-
limit: '269 KB',
329+
limit: '270 KB',
330330
disablePlugins: ['@size-limit/esbuild'],
331331
},
332332
{
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
integrations: [Sentry.spanStreamingIntegration()],
8+
ignoreSpans: [{ attributes: { 'http.status_code': 200 } }],
9+
tracesSampleRate: 1,
10+
debug: true,
11+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// This segment span matches ignoreSpans via attributes — segment + child should be dropped
2+
Sentry.startSpan({ name: 'health-check', attributes: { 'http.status_code': 200 } }, () => {
3+
Sentry.startSpan({ name: 'child-of-ignored' }, () => {});
4+
});
5+
6+
setTimeout(() => {
7+
// This segment span does NOT match — segment + child should be sent
8+
Sentry.startSpan({ name: 'normal-segment', attributes: { 'http.status_code': 500 } }, () => {
9+
Sentry.startSpan({ name: 'child-span' }, () => {});
10+
});
11+
}, 1000);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect } from '@playwright/test';
2+
import type { ClientReport } from '@sentry/core';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import {
5+
envelopeRequestParser,
6+
hidePage,
7+
shouldSkipTracingTest,
8+
waitForClientReportRequest,
9+
} from '../../../../utils/helpers';
10+
import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils';
11+
12+
sentryTest('attribute-matching ignoreSpans drops the trace', async ({ getLocalTestUrl, page }) => {
13+
sentryTest.skip(shouldSkipTracingTest());
14+
15+
const url = await getLocalTestUrl({ testDir: __dirname });
16+
17+
observeStreamedSpan(page, span => {
18+
if (span.name === 'health-check' || span.name === 'child-of-ignored') {
19+
throw new Error('Ignored span found');
20+
}
21+
return false;
22+
});
23+
24+
const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'normal-segment'));
25+
const clientReportPromise = waitForClientReportRequest(page);
26+
27+
await page.goto(url);
28+
29+
expect((await spansPromise)?.length).toBe(2);
30+
31+
await hidePage(page);
32+
33+
const clientReport = envelopeRequestParser<ClientReport>(await clientReportPromise);
34+
expect(clientReport.discarded_events).toEqual([{ category: 'span', quantity: 2, reason: 'ignored' }]);
35+
});

dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// This segment span matches ignoreSpans — should NOT produce a transaction
1+
// This segment span matches ignoreSpans — segment + child should be dropped
22
Sentry.startSpan({ name: 'ignore-segment' }, () => {
33
Sentry.startSpan({ name: 'child-of-ignored-segment' }, () => {});
44
});
55

66
setTimeout(() => {
7-
// This segment span does NOT match — should produce a transaction
7+
// This segment span does NOT match — segment + child should be sent
88
Sentry.startSpan({ name: 'normal-segment' }, () => {
99
Sentry.startSpan({ name: 'child-span' }, () => {});
1010
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
traceLifecycle: 'stream',
10+
ignoreSpans: [{ attributes: { 'http.method': 'POST' } }],
11+
clientReportFlushInterval: 1_000,
12+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import express from 'express';
2+
import cors from 'cors';
3+
import * as Sentry from '@sentry/node';
4+
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
5+
6+
const app = express();
7+
8+
app.use(cors());
9+
10+
app.get('/keep', (_req, res) => {
11+
res.send({ status: 'kept' });
12+
setTimeout(() => {
13+
// flush to avoid waiting for the span buffer timeout to send spans
14+
// but defer it to the next tick to let the SDK finish the http.server span first.
15+
Sentry.flush();
16+
});
17+
});
18+
19+
app.post('/drop', (_req, res) => {
20+
res.send({ status: 'dropped' });
21+
});
22+
23+
Sentry.setupExpressErrorHandler(app);
24+
25+
startExpressServerAndSendPortToRunner(app);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
3+
4+
describe('filtering segment spans by attribute with ignoreSpans (streaming)', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
9+
createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
10+
test('segment spans matching an attribute filter are dropped including all children', async () => {
11+
const runner = createRunner()
12+
.unignore('client_report')
13+
.expect({
14+
client_report: {
15+
discarded_events: [
16+
{
17+
category: 'span',
18+
quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored)
19+
reason: 'ignored',
20+
},
21+
],
22+
},
23+
})
24+
.expect({
25+
span: container => {
26+
expect(container.items).toHaveLength(5);
27+
const segmentSpan = container.items.find(s => s.name === 'GET /keep' && !!s.is_segment);
28+
29+
expect(segmentSpan).toBeDefined();
30+
expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true);
31+
},
32+
})
33+
.start();
34+
35+
const dropRes = await runner.makeRequest('post', '/drop');
36+
expect((dropRes as { status: string }).status).toBe('dropped');
37+
38+
const keepRes = await runner.makeRequest('get', '/keep');
39+
expect((keepRes as { status: string }).status).toBe('kept');
40+
41+
await runner.completed();
42+
});
43+
});
44+
});

packages/core/src/client.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,7 +1600,13 @@ function processBeforeSend(
16001600
const rootSpanJson = convertTransactionEventToSpanJson(processedEvent);
16011601

16021602
// 1.1 If the root span should be ignored, drop the whole transaction
1603-
if (ignoreSpans?.length && shouldIgnoreSpan(rootSpanJson, ignoreSpans)) {
1603+
if (
1604+
ignoreSpans?.length &&
1605+
shouldIgnoreSpan(
1606+
{ description: rootSpanJson.description, op: rootSpanJson.op, attributes: rootSpanJson.data },
1607+
ignoreSpans,
1608+
)
1609+
) {
16041610
// dropping the whole transaction!
16051611
return null;
16061612
}
@@ -1624,7 +1630,10 @@ function processBeforeSend(
16241630

16251631
for (const span of initialSpans) {
16261632
// 2.a If the child span should be ignored, reparent it to the root span
1627-
if (ignoreSpans?.length && shouldIgnoreSpan(span, ignoreSpans)) {
1633+
if (
1634+
ignoreSpans?.length &&
1635+
shouldIgnoreSpan({ description: span.description, op: span.op, attributes: span.data }, ignoreSpans)
1636+
) {
16281637
reparentChildSpans(initialSpans, span);
16291638
continue;
16301639
}

packages/core/src/envelope.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,10 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?
142142
const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {};
143143

144144
const filteredSpans = ignoreSpans?.length
145-
? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans))
145+
? spans.filter(span => {
146+
const json = spanToJSON(span);
147+
return !shouldIgnoreSpan({ description: json.description, op: json.op, attributes: json.data }, ignoreSpans);
148+
})
146149
: spans;
147150
const droppedSpans = spans.length - filteredSpans.length;
148151

0 commit comments

Comments
 (0)