Skip to content

Commit 5e76abe

Browse files
authored
feat(core)!: Make beforeSendSpan compatible with streamed spans by default (#22643)
This PR changes `beforeSendSpan` to receive `StreamedSpanJSON` by default, matching the new trace lifecycle default. Callbacks that intentionally process legacy transaction span JSON need to add the `withStaticSpan` wrapper that marks the callback as "static"-compatible and hands users the `SpanJSON` type they used in the callback beforehand. The `withStreamedSpan`wrapper remains available as a deprecated compatibility helper and is scheduled for removal in version 12. More changes: * invalid `beforeSendSpan` callbacks no longer lead to switching the `traceLifecycle`. Since it now has a default and users need to actively opt out of span streaming, I think it's fair to treat incompatible callbacks as "invalid" and hence skip over them. * The `beforeSendSpan` compatibility checks were moved from the integrations into the core client which ensures that they always run now, even if users selected the `static` life cycle and hence `spanStreamingIntegration` doesn't get added. * Because we removed sending INP spans as v1 (`SpanJSON`) spans in favour of always sending them as v2 spans, we now convert a `StreamedSpanJson` to `SpanJson` in `captureSpan`, hand it to the static callback and then convert it back. Not great but I think we need to let users still scrub INP spans. Closes #22349
1 parent bbda884 commit 5e76abe

49 files changed

Lines changed: 651 additions & 217 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

MIGRATION.md

Lines changed: 153 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,161 @@ The same applies to the no-code entry points, e.g. `node --import=@sentry/node/i
194194

195195
Affected SDKs: All SDKs.
196196

197-
Each span is sent to Sentry the moment it finishes instead of being buffered until the root span completes. This means spans are no longer bound by the 1000-span per transaction limit and their individual payload-size limits have been increased.
197+
Spans are now sent to Sentry in small batches instead of being buffered until the root span completes.
198+
This means spans are no longer bound by the 1000-span per transaction limit and their individual payload-size limits have been increased.
198199

199-
The new model comes with some changes to Sentry hooks such as `beforeSendSpan` or options like `ignoreSpans` and requires manual migration. `beforeSendTransaction` and `ignoreTransactions` will **no-op**. Users who cannot migrate yet can opt into the previous transaction-based static model.
200+
The new model comes with some changes to Sentry hooks such as `beforeSendSpan` or options like `ignoreSpans` and requires manual migration.
201+
The `beforeSendTransaction` and `ignoreTransactions` options will **no-op**.
202+
If you cannot migrate to span streaming yet, you can opt into the previous transaction-based static model.
200203

201-
> **TODO(v11):** The migration path for span streaming is still being defined. Document:
202-
>
203-
> - the concrete before/after for `beforeSendSpan` and `ignoreSpans`,
204-
> - the exact replacement for `beforeSendTransaction` / `ignoreTransactions`,
205-
> - how to opt back into the transaction-based model (option name + example).
204+
#### `beforeSendSpan` receives the streamed span format
205+
206+
Your `beforeSendSpan` callback now receives a `StreamedSpanJSON` object and is invoked as each span finishes, rather than for all spans of a transaction right before that transaction is sent. As in v10, it is invoked for the root span as well as for child spans.
207+
208+
The payload fields were renamed:
209+
210+
| Before (`SpanJSON`) | After (`StreamedSpanJSON`) |
211+
| ------------------- | ------------------------------ |
212+
| `description` | `name` |
213+
| `data` | `attributes` |
214+
| `op` | `attributes['sentry.op']` |
215+
| `timestamp` | `end_timestamp` |
216+
| `status` (`string`) | `status` (`'ok'` or `'error'`) |
217+
218+
The `status` field, now only contains two statuses: `'ok'` and `'error'`.
219+
Streamed spans always have a status (while status was optional on transaction-based spans).
220+
Previously more fine-grained error statuses are now mapped to `'error'`.
221+
Additional error information may be set via span attributes (e.g. `sentry.status.message`).
222+
223+
```js
224+
// Before
225+
Sentry.init({
226+
beforeSendSpan: span => {
227+
if (span.op === 'db.query') {
228+
span.description = scrub(span.description);
229+
span.data['db.statement'] = scrub(span.data['db.statement']);
230+
}
231+
return span;
232+
},
233+
});
234+
235+
// After
236+
Sentry.init({
237+
beforeSendSpan: span => {
238+
if (span.attributes?.['sentry.op'] === 'db.query') {
239+
span.name = scrub(span.name);
240+
span.attributes['db.statement'] = scrub(span.attributes['db.statement']);
241+
}
242+
return span;
243+
},
244+
});
245+
```
246+
247+
Returning `null` to drop a span was already disallowed in v9 and remains a no-op. Use `ignoreSpans` to filter spans.
248+
249+
If you cannot migrate the callback yet, opt out of span streaming and wrap `beforeSendSpan` with `Sentry.withStaticSpan()`:
250+
251+
```js
252+
Sentry.init({
253+
traceLifecycle: 'static',
254+
beforeSendSpan: Sentry.withStaticSpan(span => {
255+
span.description = scrub(span.description);
256+
return span;
257+
}),
258+
});
259+
```
260+
261+
A `beforeSendSpan` callback that does not match the configured `traceLifecycle` is **never invoked** — an unwrapped callback is ignored in `'static'` mode, and a `withStaticSpan`-wrapped callback is ignored in `'stream'` mode. Enable debug logging to surface a warning about the mismatch. Previously, an incompatible callback silently downgraded the SDK to the static lifecycle instead.
262+
263+
The `withStreamedSpan()` helper is now a no-op, since streamed payloads are the default. It is deprecated and will be removed in v12. You can remove the wrapper:
264+
265+
```js
266+
// Before
267+
beforeSendSpan: Sentry.withStreamedSpan(span => span);
268+
269+
// After
270+
beforeSendSpan: span => span;
271+
```
272+
273+
The internal `isStreamedBeforeSendSpanCallback()` function from `@sentry/core` was removed.
274+
275+
#### Replacing `beforeSendTransaction`
276+
277+
`beforeSendTransaction` no-ops because no transaction events are produced.
278+
For **scrubbing and data modification**, move the logic to `beforeSendSpan` and guard on `is_segment` to target what used to be the transaction
279+
For **dropping** a transaction or child spans, use `ignoreSpans` (see below). The `beforeSendSpan` callback cannot drop spans.
280+
281+
```js
282+
// Before
283+
Sentry.init({
284+
beforeSendTransaction: event => {
285+
if (event.transaction === 'GET /health') {
286+
return null;
287+
}
288+
event.transaction = scrubIds(event.transaction);
289+
return event;
290+
},
291+
});
292+
293+
// After
294+
Sentry.init({
295+
ignoreSpans: [
296+
'GET /health'
297+
]
298+
beforeSendSpan: span => {
299+
if (span.is_segment) {
300+
span.name = scrubIds(span.name);
301+
}
302+
return span;
303+
},
304+
});
305+
```
306+
307+
Note that scope `tags` and `extra` are not carried over to streamed spans, since spans only have attributes. Use `Sentry.setAttribute()` / `Sentry.setAttributes()` instead.
308+
309+
#### Replacing `ignoreTransactions` with `ignoreSpans`
310+
311+
`ignoreTransactions` no-ops. Use `ignoreSpans` to match the segment span instead: when a segment span is ignored, all of its child spans are dropped with it, which is equivalent to dropping the whole transaction.
312+
313+
```js
314+
// Before
315+
Sentry.init({
316+
ignoreTransactions: ['GET /health'],
317+
});
318+
319+
// After
320+
Sentry.init({
321+
ignoreSpans: ['GET /health'],
322+
});
323+
```
324+
325+
`ignoreSpans` matches on the span `name` (formerly `description`). Because it applies to every span rather than just to root spans, consider narrowing the filter with the object form so that child spans sharing a name are not dropped as collateral:
326+
327+
```js
328+
Sentry.init({
329+
ignoreSpans: [{ name: 'GET /health', attributes: { 'sentry.op': 'http.server' } }],
330+
});
331+
```
332+
333+
`ignoreSpans` itself is unchanged in shape, but it now takes effect when a span **starts** rather than when the transaction is sent. Matched spans are never recorded at all, which means a matched non-segment span's children are re-parented to its parent instead of being dropped.
334+
335+
#### Opting out of span streaming
336+
337+
To keep the previous transaction-based model, set `traceLifecycle: 'static'`:
338+
339+
```js
340+
Sentry.init({
341+
traceLifecycle: 'static',
342+
343+
// `beforeSendSpan` MUST be wrapped with Sentry.withStaticSpan:
344+
beforeSendSpan: Sentry.withStaticSpan(span => {
345+
span.description = scrub(span.description);
346+
return span;
347+
}),
348+
});
349+
```
350+
351+
In Node, Bun, Vercel Edge and Cloudflare you can also set the `SENTRY_TRACE_LIFECYCLE=static` environment variable instead. The static lifecycle only exists for backwards compatibility and is planned for removal in a future major version, so treat this as a temporary measure.
206352
207353
### Logs are enabled by default
208354

dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ window.Sentry = Sentry;
44

55
Sentry.init({
66
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7-
integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
7+
integrations: [Sentry.browserTracingIntegration()],
88
tracesSampleRate: 1,
9-
beforeSendSpan: Sentry.withStreamedSpan(span => {
9+
beforeSendSpan: span => {
1010
if (span.attributes['sentry.op'] === 'pageload') {
1111
span.name = 'customPageloadSpanName';
1212
span.links = [
@@ -24,5 +24,5 @@ Sentry.init({
2424
span.status = 'something';
2525
}
2626
return span;
27-
}),
27+
},
2828
});

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ Sentry.init({
1717
tracesSampleRate: 1,
1818
// A plain (non-streamed) `beforeSendSpan` operates on the v1 `SpanJSON`. INP is sent as a v2 span,
1919
// so this verifies the static callback still runs and its changes are carried into the v2 span.
20-
beforeSendSpan: span => {
20+
beforeSendSpan: Sentry.withStaticSpan(span => {
2121
if (span.op === 'ui.interaction.click') {
2222
span.description = 'scrubbed';
2323
span.data['custom.attribute'] = 'from-before-send-span';
2424
}
2525

2626
return span;
27-
},
27+
}),
2828
debug: true,
2929
});
3030

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { SpanJSON } from '@sentry/core';
2+
import type { NodeOptions } from '@sentry/node';
3+
import * as Sentry from '@sentry/node';
4+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
5+
6+
// Simulates a callback that was not migrated to `withStaticSpan`. The cast stands in for the
7+
// JavaScript users who don't get a type error here.
8+
const unmigratedBeforeSendSpan = ((span: SpanJSON) => {
9+
span.description = 'thisShouldNotBeApplied';
10+
return span;
11+
}) as unknown as NodeOptions['beforeSendSpan'];
12+
13+
Sentry.init({
14+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
15+
tracesSampleRate: 1.0,
16+
transport: loggingTransport,
17+
release: '1.0.0',
18+
traceLifecycle: 'static',
19+
beforeSendSpan: unmigratedBeforeSendSpan,
20+
});
21+
22+
Sentry.startSpan({ name: 'test-span', op: 'test' }, () => {
23+
Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => {
24+
// noop
25+
});
26+
});
27+
28+
void Sentry.flush();
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
tracesSampleRate: 1.0,
7+
transport: loggingTransport,
8+
release: '1.0.0',
9+
traceLifecycle: 'static',
10+
beforeSendSpan: Sentry.withStaticSpan(span => {
11+
if (span.description === 'test-child-span') {
12+
span.description = 'customChildSpanName';
13+
span.data['sentry.custom_attribute'] = 'customAttributeValue';
14+
}
15+
16+
if (span.is_segment) {
17+
span.description = 'customRootSpanName';
18+
span.data['sentry.custom_root_attribute'] = 'customRootAttributeValue';
19+
}
20+
21+
return span;
22+
}),
23+
});
24+
25+
Sentry.startSpan({ name: 'test-span', op: 'test' }, () => {
26+
Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => {
27+
// noop
28+
});
29+
});
30+
31+
void Sentry.flush();
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('withStaticSpan applies changes to child spans', async () => {
9+
await createRunner(__dirname, 'scenario.ts')
10+
.expect({
11+
transaction: event => {
12+
expect(event.spans).toHaveLength(1);
13+
14+
const childSpan = event.spans![0]!;
15+
expect(childSpan.description).toBe('customChildSpanName');
16+
expect(childSpan.data['sentry.custom_attribute']).toBe('customAttributeValue');
17+
},
18+
})
19+
.start()
20+
.completed();
21+
});
22+
23+
test('withStaticSpan applies changes to the root span', async () => {
24+
await createRunner(__dirname, 'scenario.ts')
25+
.expect({
26+
transaction: event => {
27+
expect(event.transaction).toBe('customRootSpanName');
28+
expect(event.contexts?.trace?.data?.['sentry.custom_root_attribute']).toBe('customRootAttributeValue');
29+
},
30+
})
31+
.start()
32+
.completed();
33+
});
34+
35+
test('a beforeSendSpan callback without withStaticSpan is not invoked in the static trace lifecycle', async () => {
36+
await createRunner(__dirname, 'scenario-unwrapped.ts')
37+
.expect({
38+
transaction: event => {
39+
expect(event.transaction).toBe('test-span');
40+
expect(event.spans).toHaveLength(1);
41+
expect(event.spans![0]!.description).toBe('test-child-span');
42+
},
43+
})
44+
.start()
45+
.completed();
46+
});

dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests';
44
Sentry.init({
55
dsn: 'https://public@dsn.ingest.sentry.io/1337',
66
tracesSampleRate: 1.0,
7-
traceLifecycle: 'stream',
87
transport: loggingTransport,
98
release: '1.0.0',
10-
beforeSendSpan: Sentry.withStreamedSpan(span => {
9+
beforeSendSpan: span => {
1110
if (span.name === 'test-child-span') {
1211
span.name = 'customChildSpanName';
1312
if (!span.attributes) {
@@ -27,7 +26,7 @@ Sentry.init({
2726
];
2827
}
2928
return span;
30-
}),
29+
},
3130
});
3231

3332
Sentry.startSpan({ name: 'test-span', op: 'test' }, () => {

dev-packages/rollup-utils/plugins/bundlePlugins.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ export function makeTerserPlugin() {
150150
'_resolveFilename',
151151
// Set on e.g. the shim feedbackIntegration to be able to detect it
152152
'_isShim',
153-
// Marker set by `withStreamedSpan()` to tag streamed `beforeSendSpan` callbacks
154-
'_streamed',
153+
// Marker used to detect `beforeSendSpan` callbacks expecting the static span format
154+
'_static',
155155
// This is used in metadata integration
156156
'_sentryModuleMetadata',
157157
],

packages/astro/src/index.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ export {
172172
unleashIntegration,
173173
growthbookIntegration,
174174
spanStreamingIntegration,
175+
withStaticSpan,
176+
// oxlint-disable-next-line typescript/no-deprecated
175177
withStreamedSpan,
176178
metrics,
177179
} from '@sentry/node';

packages/astro/src/index.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | NodeO
2121
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
2222
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
2323
export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
24+
export declare const withStaticSpan: typeof clientSdk.withStaticSpan;
25+
// oxlint-disable-next-line typescript/no-deprecated
2426
export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan;
2527

2628
export declare const getDefaultIntegrations: (options: Options) => Integration[];

0 commit comments

Comments
 (0)