diff --git a/.size-limit.js b/.size-limit.js index d589cfccdc65..6ef49dad2033 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -286,7 +286,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '100 KB', + limit: '101 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/MIGRATION.md b/MIGRATION.md index 438b2572f682..13a18f12df15 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -120,15 +120,150 @@ The same applies to the no-code entry points, e.g. `node --import=@sentry/node/i Affected SDKs: All SDKs. -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. +Spans are now sent to Sentry in small batches 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. -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. +The new model comes with some changes to Sentry hooks such as `beforeSendSpan` or options like `ignoreSpans` and requires manual migration. +The `beforeSendTransaction` and `ignoreTransactions` options will **no-op**. +If you cannot migrate to span streaming yet, you can opt into the previous transaction-based static model. -> **TODO(v11):** The migration path for span streaming is still being defined. Document: -> -> - the concrete before/after for `beforeSendSpan` and `ignoreSpans`, -> - the exact replacement for `beforeSendTransaction` / `ignoreTransactions`, -> - how to opt back into the transaction-based model (option name + example). +#### `beforeSendSpan` receives the streamed span format + +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. + +The payload fields were renamed: + +| Before (`SpanJSON`) | After (`StreamedSpanJSON`) | +| ------------------- | ------------------------------ | +| `description` | `name` | +| `data` | `attributes` | +| `op` | `attributes['sentry.op']` | +| `timestamp` | `end_timestamp` | +| `status` (`string`) | `status` (`'ok'` or `'error'`) | + +```js +// Before +Sentry.init({ + beforeSendSpan: span => { + if (span.op === 'db.query') { + span.description = scrub(span.description); + span.data['db.statement'] = scrub(span.data['db.statement']); + } + return span; + }, +}); + +// After +Sentry.init({ + beforeSendSpan: span => { + if (span.attributes?.['sentry.op'] === 'db.query') { + span.name = scrub(span.name); + span.attributes['db.statement'] = scrub(span.attributes['db.statement']); + } + return span; + }, +}); +``` + +Returning `null` to drop a span was already disallowed in v9 and remains a no-op. Use `ignoreSpans` to filter spans. + +If you cannot migrate a callback yet, wrap it with `Sentry.withStaticSpan()` and opt out of span streaming (see below). `withStaticSpan` marks the callback as expecting the legacy `SpanJSON` format: + +```js +Sentry.init({ + traceLifecycle: 'static', + beforeSendSpan: Sentry.withStaticSpan(span => { + span.description = scrub(span.description); + return span; + }), +}); +``` + +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. + +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: + +```js +// Before +beforeSendSpan: Sentry.withStreamedSpan(span => span); + +// After +beforeSendSpan: span => span; +``` + +The internal `isStreamedBeforeSendSpanCallback()` function from `@sentry/core` is no longer exported. + +#### Replacing `beforeSendTransaction` + +`beforeSendTransaction` no-ops because no transaction events are produced. For **scrubbing and data modification**, move the logic to `beforeSendSpan` and guard on `is_segment` to target what used to be the transaction: + +```js +// Before +Sentry.init({ + beforeSendTransaction: event => { + event.transaction = scrubIds(event.transaction); + return event; + }, +}); + +// After +Sentry.init({ + beforeSendSpan: span => { + if (span.is_segment) { + span.name = scrubIds(span.name); + } + return span; + }, +}); +``` + +Note that scope `tags` and `extra` are not carried over to streamed spans, since spans only have attributes. Use `Sentry.setAttribute()` / `Sentry.setAttributes()` instead. + +For **dropping** a transaction, use `ignoreSpans` (see below). The `beforeSendSpan` callback cannot drop spans. + +#### Replacing `ignoreTransactions` with `ignoreSpans` + +`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. + +```js +// Before +Sentry.init({ + ignoreTransactions: ['GET /health'], +}); + +// After +Sentry.init({ + ignoreSpans: ['GET /health'], +}); +``` + +`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: + +```js +Sentry.init({ + ignoreSpans: [{ name: 'GET /health', attributes: { 'sentry.op': 'http.server' } }], +}); +``` + +`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. + +#### Opting out of span streaming + +To keep the previous transaction-based model, set `traceLifecycle: 'static'`: + +```js +Sentry.init({ + traceLifecycle: 'static', + + // `beforeSendSpan` MUST be wrapped with Sentry.withStaticSpan: + beforeSendSpan: Sentry.withStaticSpan(span => { + span.description = scrub(span.description); + return span; + }), +}); +``` + +In Node, 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. ### Logs are enabled by default diff --git a/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js b/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js index f099c1f61c3e..a3fae7e92b48 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, - beforeSendSpan: Sentry.withStreamedSpan(span => { + beforeSendSpan: span => { if (span.attributes['sentry.op'] === 'pageload') { span.name = 'customPageloadSpanName'; span.links = [ @@ -24,5 +24,5 @@ Sentry.init({ span.status = 'something'; } return span; - }), + }, }); diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario-unwrapped.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario-unwrapped.ts new file mode 100644 index 000000000000..e3cad96f2f45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario-unwrapped.ts @@ -0,0 +1,28 @@ +import type { SpanJSON } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Simulates a callback that was not migrated to `withStaticSpan`. The cast stands in for the +// JavaScript users who don't get a type error here. +const unmigratedBeforeSendSpan = ((span: SpanJSON) => { + span.description = 'thisShouldNotBeApplied'; + return span; +}) as unknown as NodeOptions['beforeSendSpan']; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, + release: '1.0.0', + traceLifecycle: 'static', + beforeSendSpan: unmigratedBeforeSendSpan, +}); + +Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario.ts new file mode 100644 index 000000000000..8dee60e5b99d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario.ts @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, + release: '1.0.0', + traceLifecycle: 'static', + beforeSendSpan: Sentry.withStaticSpan(span => { + if (span.description === 'test-child-span') { + span.description = 'customChildSpanName'; + span.data['sentry.custom_attribute'] = 'customAttributeValue'; + } + + if (span.is_segment) { + span.description = 'customRootSpanName'; + span.data['sentry.custom_root_attribute'] = 'customRootAttributeValue'; + } + + return span; + }), +}); + +Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/test.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/test.ts new file mode 100644 index 000000000000..1dc4c63b042b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/test.ts @@ -0,0 +1,46 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('withStaticSpan applies changes to child spans', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: event => { + expect(event.spans).toHaveLength(1); + + const childSpan = event.spans![0]!; + expect(childSpan.description).toBe('customChildSpanName'); + expect(childSpan.data['sentry.custom_attribute']).toBe('customAttributeValue'); + }, + }) + .start() + .completed(); +}); + +test('withStaticSpan applies changes to the root span', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('customRootSpanName'); + expect(event.contexts?.trace?.data?.['sentry.custom_root_attribute']).toBe('customRootAttributeValue'); + }, + }) + .start() + .completed(); +}); + +test('a beforeSendSpan callback without withStaticSpan is not invoked in the static trace lifecycle', async () => { + await createRunner(__dirname, 'scenario-unwrapped.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('test-span'); + expect(event.spans).toHaveLength(1); + expect(event.spans![0]!.description).toBe('test-child-span'); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts index 3c7c0ed81edf..3e885e4170f9 100644 --- a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts @@ -4,10 +4,9 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, - traceLifecycle: 'stream', transport: loggingTransport, release: '1.0.0', - beforeSendSpan: Sentry.withStreamedSpan(span => { + beforeSendSpan: span => { if (span.name === 'test-child-span') { span.name = 'customChildSpanName'; if (!span.attributes) { @@ -27,7 +26,7 @@ Sentry.init({ ]; } return span; - }), + }, }); Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { diff --git a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index ecf793e49585..23aab90f4edf 100644 --- a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs +++ b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs @@ -150,8 +150,8 @@ export function makeTerserPlugin() { '_resolveFilename', // Set on e.g. the shim feedbackIntegration to be able to detect it '_isShim', - // Marker set by `withStreamedSpan()` to tag streamed `beforeSendSpan` callbacks - '_streamed', + // Marker used to detect `beforeSendSpan` callbacks expecting the static span format + '_static', // This is used in metadata integration '_sentryModuleMetadata', ], diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index cb157ec9007e..f503915569af 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -173,6 +173,8 @@ export { unleashIntegration, growthbookIntegration, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, metrics, } from '@sentry/node'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index 0c8a5cca7bb6..3472adfad925 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -21,6 +21,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | NodeO export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 30f597757b40..991ef239696a 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -160,6 +160,8 @@ export { growthbookIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index d1e9d7662db3..86e038cbb850 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -72,6 +72,8 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, metrics, } from '@sentry/core/browser'; diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index e8ed1a0de537..9b617ba4b014 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -4,34 +4,19 @@ import { debug, defineIntegration, hasSpanStreamingEnabled, - isStreamedBeforeSendSpanCallback, SpanBuffer, spanIsSampled, } from '@sentry/core/browser'; import { DEBUG_BUILD } from '../debug-build'; +const INTEGRATION_NAME = 'SpanStreaming' as const; + export const spanStreamingIntegration = defineIntegration(() => { return { - name: 'SpanStreaming' as const, - + name: INTEGRATION_NAME, setup(client) { - const initialMessage = 'SpanStreaming integration requires'; - const fallbackMsg = 'Falling back to static trace lifecycle.'; - const clientOptions = client.getOptions(); - if (!hasSpanStreamingEnabled(client)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); - return; - } - - const beforeSendSpan = clientOptions.beforeSendSpan; - // If users misconfigure their SDK by opting into span streaming but - // using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle. - if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && - debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + DEBUG_BUILD && debug.log(`[${INTEGRATION_NAME}] \`traceLifecycle\` is "static", skipping setup.`); return; } diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index d3bf3c960d6e..0309aed98248 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -116,9 +116,7 @@ export function init(options: BrowserOptions = {}): Client | undefined { defaultIntegrations, }); - options.traceLifecycle ??= 'stream'; - - if (options.traceLifecycle === 'stream' && !integrations.some(integration => integration.name === 'SpanStreaming')) { + if (options.traceLifecycle !== 'static' && !integrations.some(integration => integration.name === 'SpanStreaming')) { integrations.push(spanStreamingIntegration()); } diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index db4209a823e8..16c93f6c8f12 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -6,7 +6,7 @@ import { } from '@sentry/core/browser'; import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BrowserClient, spanStreamingIntegration } from '../../src'; +import { BrowserClient, spanStreamingIntegration, withStaticSpan } from '../../src'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; // Mock SpanBuffer as a class that can be instantiated @@ -40,62 +40,60 @@ describe('spanStreamingIntegration', () => { expect(integration.setup).toBeDefined(); }); - it.each(['static', 'somethingElse'])( - 'logs a warning if traceLifecycle is not set to "stream" but to %s', - traceLifecycle => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); - const client = new BrowserClient({ - ...getDefaultBrowserClientOptions(), - dsn: 'https://username@domain/123', - integrations: [spanStreamingIntegration()], - // @ts-expect-error - we want to test the warning for invalid traceLifecycle values - traceLifecycle, - }); - - SentryCore.setCurrentClient(client); - client.init(); - - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', - ); - debugSpy.mockRestore(); - - expect(client.getOptions().traceLifecycle).toBe('static'); - }, - ); - - it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + it('does not set up span streaming if traceLifecycle is "static"', () => { + const debugSpy = vi.spyOn(debug, 'log').mockImplementation(() => {}); const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], - traceLifecycle: 'stream', - beforeSendSpan: (span: Span) => span, + traceLifecycle: 'static', + tracesSampleRate: 1, }); SentryCore.setCurrentClient(client); client.init(); - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', - ); + expect(debugSpy).toHaveBeenCalledWith('[SpanStreaming] `traceLifecycle` is "static", skipping setup.'); debugSpy.mockRestore(); - expect(client.getOptions().traceLifecycle).toBe('static'); + expect(MockSpanBuffer).not.toHaveBeenCalled(); + + // Without the hooks registered, ending a span must not enqueue anything + client.emit('afterSpanEnd', new SentryCore.SentrySpan({ name: 'test', sampled: true })); + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + }); + + it.each([ + ['explicitly set to "stream"', 'stream' as const], + ['left unset', undefined], + ])('sets up span streaming if traceLifecycle is %s', (_, traceLifecycle) => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(MockSpanBuffer).toHaveBeenCalledTimes(1); + expect(client.getOptions().traceLifecycle).toBe('stream'); }); - it('does nothing if traceLifecycle set to "stream"', () => { + it('still sets up span streaming if beforeSendSpan is wrapped with withStaticSpan', () => { const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], traceLifecycle: 'stream', + beforeSendSpan: withStaticSpan(span => span), }); SentryCore.setCurrentClient(client); client.init(); + expect(MockSpanBuffer).toHaveBeenCalledTimes(1); expect(client.getOptions().traceLifecycle).toBe('stream'); }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4a6c25eed27d..353d16545358 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -178,6 +178,8 @@ export { unleashIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index a95da03e25ec..46e8effb6c34 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -110,6 +110,8 @@ export { growthbookIntegration, logger, metrics, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, instrumentStateGraph, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 6076b065cf62..eeecc515eac6 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -11,7 +11,7 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; -import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; +import { isStaticBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import { extractGenAiSpansFromEvent } from './tracing/spans/extractGenAiSpans'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types/breadcrumb'; @@ -233,7 +233,13 @@ export abstract class Client { * @param options Options for the client. */ protected constructor(options: O) { - this._options = { attachStacktrace: true, traceLifecycle: 'stream', ...options }; + // Any value other than `'static'` normalizes to the `'stream'` default, so that `traceLifecycle` + // is always one of the two known values for the rest of the SDK. + this._options = { + attachStacktrace: true, + ...options, + traceLifecycle: options.traceLifecycle === 'static' ? 'static' : 'stream', + }; this._integrations = {}; this._numProcessing = 0; this._outcomes = {}; @@ -249,6 +255,21 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } + const { beforeSendSpan, traceLifecycle } = this._options; + // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, + // so a mismatch means it is silently never called. + if ( + DEBUG_BUILD && + beforeSendSpan && + isStaticBeforeSendSpanCallback(beforeSendSpan) !== (traceLifecycle === 'static') + ) { + debug.warn( + `Ignoring \`beforeSendSpan\`: ${ + traceLifecycle === 'static' ? 'wrap it with' : 'remove' + } \`Sentry.withStaticSpan\` to use it with \`traceLifecycle: "${traceLifecycle}"\`.`, + ); + } + if (this._dsn) { const url = getEnvelopeEndpointWithUrlEncodedAuth( this._dsn, @@ -1654,7 +1675,7 @@ function processBeforeSend( hint: EventHint, ): PromiseLike | Event | null { const { beforeSend, beforeSendTransaction, ignoreSpans } = options; - const beforeSendSpan = !isStreamedBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; + const beforeSendSpan = isStaticBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; let processedEvent = event; diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts index ffd5d0bb7694..662e8c83a093 100644 --- a/packages/core/src/integrations/spanStreaming.ts +++ b/packages/core/src/integrations/spanStreaming.ts @@ -1,33 +1,21 @@ import type { IntegrationFn } from '../types/integration'; import { DEBUG_BUILD } from '../debug-build'; import { defineIntegration } from '../integration'; -import { isStreamedBeforeSendSpanCallback } from '../tracing/spans/beforeSendSpan'; import { captureSpan } from '../tracing/spans/captureSpan'; import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { SpanBuffer } from '../tracing/spans/spanBuffer'; import { debug } from '../utils/debug-logger'; import { spanIsSampled } from '../utils/spanUtils'; +const INTEGRATION_NAME = 'SpanStreaming' as const; + export const spanStreamingIntegration = defineIntegration(() => { return { - name: 'SpanStreaming' as const, + name: INTEGRATION_NAME, setup(client) { - const initialMessage = 'SpanStreaming integration requires'; - const fallbackMsg = 'Falling back to static trace lifecycle.'; - const clientOptions = client.getOptions(); - if (!hasSpanStreamingEnabled(client)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); - return; - } - - const beforeSendSpan = clientOptions.beforeSendSpan; - if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && - debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + DEBUG_BUILD && debug.log(`[${INTEGRATION_NAME}] \`traceLifecycle\` is "static", skipping setup.`); return; } diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index ec10c516ef9b..ae564663f6c7 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -40,12 +40,10 @@ export class ServerRuntimeClient< public constructor(options: O) { addUserAgentToTransportHeaders(options); - options.traceLifecycle ??= 'stream'; - // When span streaming is enabled (`traceLifecycle: 'stream'`), the `spanStreamingIntegration` // is required to flush spans. We add it here so the individual server SDKs don't have to. // A user-provided `spanStreamingIntegration` always takes precedence over the one we add. - if (options.traceLifecycle === 'stream' && !options.integrations.some(i => i.name === 'SpanStreaming')) { + if (options.traceLifecycle !== 'static' && !options.integrations.some(i => i.name === 'SpanStreaming')) { options.integrations.push(spanStreamingIntegration()); } diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 700e65d2a974..daec7c8a6a88 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -86,8 +86,11 @@ export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; -export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; -export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; +export { + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated + withStreamedSpan, +} from './tracing/spans/beforeSendSpan'; export { safeSetSpanJSONAttributes } from './tracing/spans/captureSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; diff --git a/packages/core/src/tracing/spans/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts index 57797e5daead..f577af902f37 100644 --- a/packages/core/src/tracing/spans/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -1,42 +1,62 @@ -import type { CoreOptions } from '../../types/options'; -import type { BeforeSendStreamedSpanCallback } from '../../types/options'; -import type { StreamedSpanJSON } from '../../types/span'; +import type { BeforeSendStaticSpanCallback, BeforeSendStreamedSpanCallback } from '../../types/options'; +import type { SpanJSON, StreamedSpanJSON } from '../../types/span'; import { addNonEnumerableProperty } from '../../utils/object'; -type StaticBeforeSendSpanCallback = CoreOptions['beforeSendSpan']; - /** - * A wrapper to use the new span format in your `beforeSendSpan` callback. + * A wrapper to use the static, transaction-based span format in your `beforeSendSpan` callback. * - * When using `traceLifecycle: 'stream'`, wrap your callback with this function - * to receive and return {@link StreamedSpanJSON} instead of the standard {@link SpanJSON}. + * When using `traceLifecycle: 'static'`, wrap your callback with this function + * to receive and return {@link SpanJSON} instead of {@link StreamedSpanJSON}. * * @example * * Sentry.init({ - * traceLifecycle: 'stream', - * beforeSendSpan: withStreamedSpan((span) => { - * // span is of type StreamedSpanJSON + * traceLifecycle: 'static', + * beforeSendSpan: withStaticSpan((span) => { + * // span is of type SpanJSON * return span; * }), * }); * + * @param callback - The callback function that receives and returns a {@link SpanJSON}. + * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'static'`. + */ +export function withStaticSpan(callback: (span: SpanJSON) => SpanJSON): BeforeSendStaticSpanCallback { + addNonEnumerableProperty(callback, '_static', true); + return callback as BeforeSendStaticSpanCallback; +} + +/** + * A wrapper to explicitly use the streamed span format in your `beforeSendSpan` callback. + * + * @deprecated `beforeSendSpan` callbacks receive {@link StreamedSpanJSON} by default. + * This function returns the callback unchanged and will be removed in SDK version 12. + * * @param callback - The callback function that receives and returns a {@link StreamedSpanJSON}. - * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'stream'`. + * @returns The provided callback. */ export function withStreamedSpan( callback: (span: StreamedSpanJSON) => StreamedSpanJSON, -): StaticBeforeSendSpanCallback & { _streamed: true } { - addNonEnumerableProperty(callback, '_streamed', true); - return callback as unknown as StaticBeforeSendSpanCallback & { _streamed: true }; +): BeforeSendStreamedSpanCallback { + return callback; +} + +/** + * Typesafe check to identify if a `beforeSendSpan` callback expects the static span JSON format. + * + * @param callback - The `beforeSendSpan` callback to check. + * @returns `true` if the callback was wrapped with {@link withStaticSpan}. + */ +export function isStaticBeforeSendSpanCallback(callback: unknown): callback is BeforeSendStaticSpanCallback { + return !!callback && typeof callback === 'function' && '_static' in callback && !!callback._static; } /** * Typesafe check to identify if a `beforeSendSpan` callback expects the streamed span JSON format. * * @param callback - The `beforeSendSpan` callback to check. - * @returns `true` if the callback was wrapped with {@link withStreamedSpan}. + * @returns `true` unless the callback was marked as a static span callback. */ export function isStreamedBeforeSendSpanCallback(callback: unknown): callback is BeforeSendStreamedSpanCallback { - return !!callback && typeof callback === 'function' && '_streamed' in callback && !!callback._streamed; + return !!callback && typeof callback === 'function' && !isStaticBeforeSendSpanCallback(callback); } diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index b65f85b02d46..47c6ee6ac021 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -11,7 +11,7 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; -import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types/span'; +import type { SerializedStreamedSpan, Span, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, @@ -20,8 +20,10 @@ import { streamedSpanJsonToSerializedSpan, } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; -import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { isStaticBeforeSendSpanCallback, isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { hasSpanStreamingEnabled } from './hasSpanStreamingEnabled'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; +import { spanJsonToStreamedSpanJson, streamedSpanJsonToSpanJson } from './spanJsonToStreamedSpan'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { SENTRY_SDK_NAME, @@ -73,11 +75,7 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW // This also invokes the `processSpan` hook of all integrations client.emit('processSpan', spanJSON); - const { beforeSendSpan } = client.getOptions(); - const processedSpan = - beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan) - ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) - : spanJSON; + const processedSpan = applyBeforeSendSpan(spanJSON, client); const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; if (spanJSON.is_segment && spanNameSource) { @@ -152,13 +150,39 @@ function applyCommonSpanAttributes( }); } +/** + * Apply the configured `beforeSendSpan` callback to a span JSON, in whichever span format the + * callback expects. + * + * Standalone spans (INP web vital spans) are sent as v2 spans even when the static trace + * lifecycle is configured, so a `withStaticSpan` callback would otherwise never see them. For those, + * the span is converted to the v1 format for the callback and converted back afterwards. + * + * TODO(v12): Remove the v1 and static callback conversion shenanigans once we drop transactions. + */ +function applyBeforeSendSpan(span: StreamedSpanJSON, client: Client): StreamedSpanJSON { + const { beforeSendSpan } = client.getOptions(); + + if (!beforeSendSpan) { + return span; + } + + if (hasSpanStreamingEnabled(client)) { + return isStreamedBeforeSendSpanCallback(beforeSendSpan) ? applyBeforeSendSpanCallback(span, beforeSendSpan) : span; + } + + return isStaticBeforeSendSpanCallback(beforeSendSpan) + ? spanJsonToStreamedSpanJson(applyBeforeSendSpanCallback(streamedSpanJsonToSpanJson(span), beforeSendSpan)) + : span; +} + /** * Apply a user-provided beforeSendSpan callback to a span JSON. */ -export function applyBeforeSendSpanCallback( - span: StreamedSpanJSON, - beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON, -): StreamedSpanJSON { +export function applyBeforeSendSpanCallback( + span: T, + beforeSendSpan: (span: T) => T, +): T { const modifedSpan = beforeSendSpan(span); if (!modifedSpan) { showSpanDropWarning(); diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index de664b802138..083a30246603 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -1,12 +1,24 @@ import type { RawAttributes } from '../../attributes'; -import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types/span'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { SpanLinkJSON } from '../../types/link'; +import type { SerializedStreamedSpan, SpanAttributes, SpanJSON, SpanOrigin, StreamedSpanJSON } from '../../types/span'; import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils'; /** - * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. + * Converts a v1 SpanJSON (from a legacy transaction) to an intermediate v2 StreamedSpanJSON. */ -export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { - const streamedSpan: StreamedSpanJSON = { +export function spanJsonToStreamedSpanJson(span: SpanJSON): StreamedSpanJSON { + // `op` and `origin` are dedicated fields on a v1 span but plain attributes on a v2 span. Copy them + // back so that writes to the v1 fields (e.g. in a `withStaticSpan` callback) aren't dropped. + const attributes = { ...span.data } as RawAttributes>; + if (span.op !== undefined) { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = span.op; + } + if (span.origin !== undefined) { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = span.origin; + } + + return { trace_id: span.trace_id, span_id: span.span_id, parent_span_id: span.parent_span_id, @@ -14,10 +26,43 @@ export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStre start_timestamp: span.start_timestamp, end_timestamp: span.timestamp || span.start_timestamp, status: !span.status || span.status === 'ok' || span.status === 'cancelled' ? 'ok' : 'error', - is_segment: false, - attributes: { ...(span.data as RawAttributes>) }, + is_segment: span.is_segment ?? false, + attributes, links: span.links, }; +} + +/** + * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. + */ +export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { + return streamedSpanJsonToSerializedSpan(spanJsonToStreamedSpanJson(span)); +} - return streamedSpanJsonToSerializedSpan(streamedSpan); +/** + * Converts an intermediate v2 StreamedSpanJSON back to a v1 SpanJSON. + * + * The inverse of {@link spanJsonToStreamedSpanJson}, used to hand a `withStaticSpan` `beforeSendSpan` + * callback the v1 format it expects for spans that are streamed despite the static trace lifecycle. + * + * v1 fields that have no v2 counterpart (`measurements`, `segment_id`) are not restored. + * `profile_id` and `exclusive_time` stay readable through `data`. + */ +export function streamedSpanJsonToSpanJson(span: StreamedSpanJSON): SpanJSON { + const data = { ...span.attributes } as SpanAttributes; + + return { + trace_id: span.trace_id, + span_id: span.span_id, + parent_span_id: span.parent_span_id, + description: span.name, + start_timestamp: span.start_timestamp, + timestamp: span.end_timestamp, + status: span.status, + is_segment: span.is_segment, + data, + op: data[SEMANTIC_ATTRIBUTE_SENTRY_OP], + origin: data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, + links: span.links as SpanLinkJSON[] | undefined, + }; } diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index e5f938082509..afe2bd41f4ab 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -628,16 +628,19 @@ export interface ClientOptions PromiseLike | ErrorEvent | null; /** - * This function can be defined to modify a child span before it's sent. + * This function can be defined to modify a span before it's sent. * - * When using `traceLifecycle: 'stream'`, wrap your callback with {@link withStreamedSpan} - * to receive and return {@link StreamedSpanJSON} instead. + * The callback receives and returns a {@link StreamedSpanJSON}, matching the default + * `traceLifecycle: 'stream'`. When using `traceLifecycle: 'static'`, wrap your callback with + * {@link withStaticSpan} to receive and return a {@link SpanJSON} instead. + * + * A callback that doesn't match the configured `traceLifecycle` is never invoked. * * @param span The span generated by the SDK. * * @returns The modified span payload that will be sent. */ - beforeSendSpan?: ((span: SpanJSON) => SpanJSON) & { _streamed?: true }; + beforeSendSpan?: BeforeSendStreamedSpanCallback | BeforeSendStaticSpanCallbackMarker; /** * An event-processing callback for transaction events, guaranteed to be invoked after all other event @@ -674,13 +677,19 @@ export interface ClientOptions StreamedSpanJSON) & { - /** - * When true, indicates this callback is designed to handle the {@link StreamedSpanJSON} format - * used with `traceLifecycle: 'stream'`. Set this by wrapping your callback with `withStreamedSpan`. - */ - _streamed?: true; -}; +export type BeforeSendStreamedSpanCallback = (span: StreamedSpanJSON) => StreamedSpanJSON; + +/** + * The marker that {@link withStaticSpan} adds to a `beforeSendSpan` callback. + * + * Deliberately not callable: `beforeSendSpan` must have exactly one call signature for TypeScript to + * contextually type an unwrapped callback's parameter as {@link StreamedSpanJSON}. A union of two + * callable types would infer `any` instead. + */ +type BeforeSendStaticSpanCallbackMarker = { _static: true }; + +/** A callback for processing spans sent as part of transaction events. */ +export type BeforeSendStaticSpanCallback = ((span: SpanJSON) => SpanJSON) & BeforeSendStaticSpanCallbackMarker; /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts index 8b2badf575ff..3219fb4e14d4 100644 --- a/packages/core/test/integrations/spanStreaming.test.ts +++ b/packages/core/test/integrations/spanStreaming.test.ts @@ -34,57 +34,54 @@ describe('spanStreamingIntegration (core)', () => { expect(integration.setup).toBeDefined(); }); - it.each(['static', 'somethingElse'])( - 'logs a warning if traceLifecycle is not set to "stream" but to %s', - traceLifecycle => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); - const client = new TestClient({ - ...getDefaultTestClientOptions(), - dsn: 'https://username@domain/123', - integrations: [spanStreamingIntegration()], - // @ts-expect-error - we want to test the warning for invalid traceLifecycle values - traceLifecycle, - }); - - SentryCore.setCurrentClient(client); - client.init(); - - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', - ); - debugSpy.mockRestore(); - - expect(client.getOptions().traceLifecycle).toBe('static'); - }, - ); - - it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + it('does not set up span streaming if traceLifecycle is "static"', () => { + const debugSpy = vi.spyOn(debug, 'log').mockImplementation(() => {}); const client = new TestClient({ ...getDefaultTestClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], - traceLifecycle: 'stream', - beforeSendSpan: (span: SentryCore.SpanJSON) => span, + traceLifecycle: 'static', + tracesSampleRate: 1, }); SentryCore.setCurrentClient(client); client.init(); - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', - ); + expect(debugSpy).toHaveBeenCalledWith('[SpanStreaming] `traceLifecycle` is "static", skipping setup.'); debugSpy.mockRestore(); - expect(client.getOptions().traceLifecycle).toBe('static'); + expect(MockSpanBuffer).not.toHaveBeenCalled(); + + // Without the hooks registered, ending a span must not enqueue anything + client.emit('afterSpanEnd', new SentryCore.SentrySpan({ name: 'test', sampled: true })); + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + }); + + it.each([ + ['explicitly set to "stream"', 'stream' as const], + ['left unset', undefined], + ])('sets up span streaming if traceLifecycle is %s', (_, traceLifecycle) => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(MockSpanBuffer).toHaveBeenCalledWith(client); + expect(client.getOptions().traceLifecycle).toBe('stream'); }); - it('sets up buffer when traceLifecycle is "stream"', () => { + it('still sets up span streaming if beforeSendSpan is wrapped with withStaticSpan', () => { const client = new TestClient({ ...getDefaultTestClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], traceLifecycle: 'stream', + beforeSendSpan: SentryCore.withStaticSpan(span => span), }); SentryCore.setCurrentClient(client); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 5b0cb752f2c2..84009eb726e5 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -11,6 +11,7 @@ import { setCurrentClient, SyncPromise, withMonitor, + withStaticSpan, } from '../../src'; import * as integrationModule from '../../src/integration'; import * as logsInternalModule from '../../src/logs/internal'; @@ -94,6 +95,80 @@ describe('Client', () => { expect(consoleWarnSpy).toHaveBeenCalledTimes(0); consoleWarnSpy.mockRestore(); }); + + test('warns that a streamed beforeSendSpan is ignored with traceLifecycle "static"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ dsn: PUBLIC_DSN, traceLifecycle: 'static', beforeSendSpan: span => span }), + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring `beforeSendSpan`: wrap it with `Sentry.withStaticSpan` to use it with `traceLifecycle: "static"`.', + ); + warnSpy.mockRestore(); + }); + + test('warns that a static beforeSendSpan is ignored with traceLifecycle "stream"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + traceLifecycle: 'stream', + beforeSendSpan: withStaticSpan(span => span), + }), + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring `beforeSendSpan`: remove `Sentry.withStaticSpan` to use it with `traceLifecycle: "stream"`.', + ); + warnSpy.mockRestore(); + }); + + test('reports the normalized traceLifecycle when warning about an unknown value', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + // @ts-expect-error - we want to test normalization of invalid traceLifecycle values + traceLifecycle: 'somethingElse', + beforeSendSpan: withStaticSpan(span => span), + }), + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring `beforeSendSpan`: remove `Sentry.withStaticSpan` to use it with `traceLifecycle: "stream"`.', + ); + warnSpy.mockRestore(); + }); + + test('does not warn for a streamed beforeSendSpan with traceLifecycle "stream"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', beforeSendSpan: span => span }), + ); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('does not warn for a static beforeSendSpan with traceLifecycle "static"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + traceLifecycle: 'static', + beforeSendSpan: withStaticSpan(span => span), + }), + ); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); describe('getOptions()', () => { @@ -124,6 +199,15 @@ describe('Client', () => { expect(client.getOptions().traceLifecycle).toBe('static'); }); + + test('normalizes an unknown traceLifecycle to stream', () => { + const client = new TestClient( + // @ts-expect-error - we want to test normalization of invalid traceLifecycle values + getDefaultTestClientOptions({ traceLifecycle: 'somethingElse' }), + ); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); }); describe('getTransport()', () => { @@ -1093,7 +1177,7 @@ describe('Client', () => { test('calls `beforeSendSpan` and uses original spans without any changes', () => { expect.assertions(3); - const beforeSendSpan = vi.fn(span => span); + const beforeSendSpan = withStaticSpan(vi.fn(span => span)); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); @@ -1312,14 +1396,16 @@ describe('Client', () => { }); test('does not modify existing contexts for root span in `beforeSendSpan`', () => { - const beforeSendSpan = vi.fn((span: SpanJSON) => { - return { - ...span, - data: { - modified: 'true', - }, - }; - }); + const beforeSendSpan = withStaticSpan( + vi.fn((span: SpanJSON) => { + return { + ...span, + data: { + modified: 'true', + }, + }; + }), + ); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); @@ -1427,10 +1513,12 @@ describe('Client', () => { test('calls `beforeSendSpan` and uses the modified spans', () => { expect.assertions(4); - const beforeSendSpan = vi.fn(span => { - span.data = { version: 'bravo' }; - return span; - }); + const beforeSendSpan = withStaticSpan( + vi.fn(span => { + span.data = { version: 'bravo' }; + return span; + }), + ); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); @@ -1509,7 +1597,7 @@ describe('Client', () => { test('does not discard span and warn when returning null from `beforeSendSpan', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); + const beforeSendSpan = withStaticSpan(vi.fn(() => null as unknown as SpanJSON)); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 7522b7061234..c5c3b792ccaa 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -14,6 +14,7 @@ import { markSpanForOtelSourceInference, spanSourceWasExplicitlySet, } from '../../../src/tracing/utils'; +import { withStaticSpan } from '../../../src/tracing/spans/beforeSendSpan'; import type { Envelope } from '../../../src/types/envelope'; import type { SpanJSON } from '../../../src/types/span'; import { spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; @@ -238,128 +239,207 @@ describe('SentrySpan', () => { expect(spanToJSON(span).timestamp).toBe(endTime / 1000); }); - test('uses sampled config for standalone span', () => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - }), - ); - setCurrentClient(client); - - // @ts-expect-error Accessing private transport API - const mockSend = vi.spyOn(client._transport, 'send'); - - const notSampledSpan = new SentrySpan({ - name: 'not-sampled', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: false, + describe('standalone spans', () => { + test('uses sampled config for standalone span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + }), + ); + setCurrentClient(client); + + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + + const notSampledSpan = new SentrySpan({ + name: 'not-sampled', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: false, + }); + notSampledSpan.end(); + expect(mockSend).not.toHaveBeenCalled(); + + const sampledSpan = new SentrySpan({ + name: 'is-sampled', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + sampledSpan.end(); + expect(mockSend).toHaveBeenCalledTimes(1); }); - notSampledSpan.end(); - expect(mockSend).not.toHaveBeenCalled(); - const sampledSpan = new SentrySpan({ - name: 'is-sampled', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: true, + test('sends the span if `beforeSendSpan` does not modify the span', () => { + const beforeSendSpan = withStaticSpan(vi.fn(span => span)); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + beforeSendSpan, + }), + ); + setCurrentClient(client); + + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + expect(mockSend).toHaveBeenCalled(); }); - sampledSpan.end(); - expect(mockSend).toHaveBeenCalledTimes(1); - }); - test('sends the span if `beforeSendSpan` does not modify the span', () => { - const beforeSendSpan = vi.fn(span => span); - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - beforeSendSpan, - }), - ); - setCurrentClient(client); + test('applies a static `beforeSendSpan` to standalone spans in the static trace lifecycle', () => { + // Standalone spans are sent as v2 streamed spans even in the static trace lifecycle, so a + // `withStaticSpan` callback still has to see them — in the v1 format it expects. + // The callback mutates in place, so snapshot what it received before modifying it + let received: Partial | undefined; + const beforeSendSpan = vi.fn((span: SpanJSON) => { + received = { ...span }; + span.description = 'renamed-by-beforeSendSpan'; + return span; + }); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + traceLifecycle: 'static', + beforeSendSpan: withStaticSpan(beforeSendSpan), + }), + ); + setCurrentClient(client); + + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + expect(beforeSendSpan).toHaveBeenCalledTimes(1); + // the v1 span format, not the streamed one + expect(received).toEqual( + expect.objectContaining({ + description: 'test', + timestamp: 2, + start_timestamp: 1, + origin: 'manual', + data: expect.objectContaining({ 'sentry.origin': 'manual' }), + }), + ); - // @ts-expect-error Accessing private transport API - const mockSend = vi.spyOn(client._transport, 'send'); - const span = new SentrySpan({ - name: 'test', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: true, + expect(mockSend).toHaveBeenCalled(); + const sentSpan = mockSend.mock.calls[0]![0]![1][0][1].items[0]; + expect(sentSpan.name).toBe('renamed-by-beforeSendSpan'); }); - span.end(); - expect(mockSend).toHaveBeenCalled(); - }); - test('ignores a non-streamed `beforeSendSpan` for standalone spans', () => { - // Standalone spans are sent as v2 streamed spans, which only honor a `beforeSendSpan` wrapped - // with `withStreamedSpan`. A plain callback is ignored, so the span is sent unmodified. - const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - beforeSendSpan, - }), - ); - setCurrentClient(client); - - const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); - // @ts-expect-error Accessing private transport API - const mockSend = vi.spyOn(client._transport, 'send'); - const span = new SentrySpan({ - name: 'test', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: true, + test('ignores an unwrapped (streamed) `beforeSendSpan` for standalone spans in the static trace lifecycle', () => { + const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + traceLifecycle: 'static', + // @ts-expect-error - an unwrapped callback expects the streamed format + beforeSendSpan, + }), + ); + setCurrentClient(client); + + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(mockSend).toHaveBeenCalled(); + expect(recordDroppedEventSpy).not.toHaveBeenCalled(); }); - span.end(); - - expect(beforeSendSpan).not.toHaveBeenCalled(); - expect(mockSend).toHaveBeenCalled(); - expect(recordDroppedEventSpy).not.toHaveBeenCalled(); - }); - - test('sends a standalone span on its own and excludes it from the parent transaction', async () => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - tracesSampleRate: 1, - }), - ); - setCurrentClient(client); - const envelopes: Envelope[] = []; - client.on('beforeEnvelope', envelope => { - envelopes.push(envelope); + test('ignores a static `beforeSendSpan` for standalone spans when span streaming is enabled', () => { + const beforeSendSpan = withStaticSpan(vi.fn(() => null as unknown as SpanJSON)); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + traceLifecycle: 'stream', + beforeSendSpan, + }), + ); + setCurrentClient(client); + + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(mockSend).toHaveBeenCalled(); + expect(recordDroppedEventSpy).not.toHaveBeenCalled(); }); - startSpan({ name: 'root' }, () => { - const standaloneChild = startInactiveSpan({ name: 'inp', experimental: { standalone: true } }); - standaloneChild.end(); + test('sends a standalone span on its own and excludes it from the parent transaction', async () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + + const envelopes: Envelope[] = []; + client.on('beforeEnvelope', envelope => { + envelopes.push(envelope); + }); + + startSpan({ name: 'root' }, () => { + const standaloneChild = startInactiveSpan({ name: 'inp', experimental: { standalone: true } }); + standaloneChild.end(); + }); + + await client.flush(); + + const items = envelopes.flatMap( + envelope => envelope[1] as Array<[{ type?: string; content_type?: string }, any]>, + ); + + // The standalone child is sent on its own as a v2 streamed span... + const streamedItem = items.find(item => item[0]?.content_type === 'application/vnd.sentry.items.span.v2+json'); + expect(streamedItem?.[1]?.items?.[0]?.name).toBe('inp'); + + // ...and is NOT folded into the root transaction (no double-send). + const transactionItem = items.find(item => item[0]?.type === 'transaction'); + expect(transactionItem?.[1]?.spans?.map((span: { description?: string }) => span.description)).not.toContain( + 'inp', + ); }); - - await client.flush(); - - const items = envelopes.flatMap( - envelope => envelope[1] as Array<[{ type?: string; content_type?: string }, any]>, - ); - - // The standalone child is sent on its own as a v2 streamed span... - const streamedItem = items.find(item => item[0]?.content_type === 'application/vnd.sentry.items.span.v2+json'); - expect(streamedItem?.[1]?.items?.[0]?.name).toBe('inp'); - - // ...and is NOT folded into the root transaction (no double-send). - const transactionItem = items.find(item => item[0]?.type === 'transaction'); - expect(transactionItem?.[1]?.spans?.map((span: { description?: string }) => span.description)).not.toContain( - 'inp', - ); }); test('build TransactionEvent for basic root span', () => { diff --git a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts index 79fd838a1b27..c4b3f8817b9c 100644 --- a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts @@ -1,26 +1,53 @@ import { describe, expect, it, vi } from 'vitest'; -import { withStreamedSpan } from '../../../../src'; -import { isStreamedBeforeSendSpanCallback } from '../../../../src/tracing/spans/beforeSendSpan'; +import { withStaticSpan, withStreamedSpan } from '../../../../src'; +import { + isStaticBeforeSendSpanCallback, + isStreamedBeforeSendSpanCallback, +} from '../../../../src/tracing/spans/beforeSendSpan'; + +describe('beforeSendSpan callback formats', () => { + describe('withStaticSpan', () => { + it('marks the callback as static without making the marker enumerable', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStaticSpan(beforeSendSpan); + + expect(wrapped._static).toBe(true); + expect(Object.keys(wrapped)).not.toContain('_static'); + }); + }); -describe('beforeSendSpan for span streaming', () => { describe('withStreamedSpan', () => { - it('should be able to modify the span', () => { + it('returns the callback unchanged', () => { const beforeSendSpan = vi.fn(); - const wrapped = withStreamedSpan(beforeSendSpan); - expect(wrapped._streamed).toBe(true); + + expect(withStreamedSpan(beforeSendSpan)).toBe(beforeSendSpan); + expect(Object.keys(beforeSendSpan)).not.toContain('_streamed'); + }); + }); + + describe('isStaticBeforeSendSpanCallback', () => { + it('returns true if the callback is wrapped with withStaticSpan', () => { + const wrapped = withStaticSpan(vi.fn()); + + expect(isStaticBeforeSendSpanCallback(wrapped)).toBe(true); + }); + + it('returns false for an unwrapped callback', () => { + expect(isStaticBeforeSendSpanCallback(vi.fn())).toBe(false); }); }); describe('isStreamedBeforeSendSpanCallback', () => { + it('returns true for an unwrapped callback', () => { + expect(isStreamedBeforeSendSpanCallback(vi.fn())).toBe(true); + }); + it('returns true if the callback is wrapped with withStreamedSpan', () => { - const beforeSendSpan = vi.fn(); - const wrapped = withStreamedSpan(beforeSendSpan); - expect(isStreamedBeforeSendSpanCallback(wrapped)).toBe(true); + expect(isStreamedBeforeSendSpanCallback(withStreamedSpan(vi.fn()))).toBe(true); }); - it('returns false if the callback is not wrapped with withStreamedSpan', () => { - const beforeSendSpan = vi.fn(); - expect(isStreamedBeforeSendSpanCallback(beforeSendSpan)).toBe(false); + it('returns false if the callback is wrapped with withStaticSpan', () => { + expect(isStreamedBeforeSendSpanCallback(withStaticSpan(vi.fn()))).toBe(false); }); }); }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 0560ebd2f702..fc2aab1a3e2c 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Contexts, StreamedSpanJSON } from '../../../../src'; +import type { Contexts, SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../../../src'; import { captureSpan, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, @@ -15,11 +15,12 @@ import { SEMANTIC_ATTRIBUTE_USER_USERNAME, startInactiveSpan, startSpan, + withStaticSpan, withScope, - withStreamedSpan, } from '../../../../src'; import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { scopeContextsToSpanAttributes } from '../../../../src/tracing/spans/scopeContextAttributes'; +import type { TestClientOptions } from '../../../mocks/client'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; import { SENTRY_SEGMENT_ID, @@ -475,8 +476,8 @@ describe('captureSpan', () => { }); describe('beforeSendSpan', () => { - it('applies beforeSendSpan if it is a span streaming compatible callback', () => { - const beforeSendSpan = withStreamedSpan(vi.fn(span => span)); + it('applies a default beforeSendSpan callback', () => { + const beforeSendSpan = vi.fn(span => span); const client = new TestClient( getDefaultTestClientOptions({ @@ -484,6 +485,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -496,8 +498,8 @@ describe('captureSpan', () => { expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); }); - it("doesn't apply beforeSendSpan if it is not a span streaming compatible callback", () => { - const beforeSendSpan = vi.fn(span => span); + it("doesn't apply beforeSendSpan if it is marked as static by default", () => { + const beforeSendSpan = withStaticSpan(vi.fn(span => span)); const client = new TestClient( getDefaultTestClientOptions({ @@ -505,6 +507,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -519,8 +522,7 @@ describe('captureSpan', () => { it('logs a warning if the beforeSendSpan callback returns null', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - // @ts-expect-error - the types dissallow returning null but this is javascript, so we need to test it - const beforeSendSpan = withStreamedSpan(() => null); + const beforeSendSpan = vi.fn(() => null as unknown as StreamedSpanJSON); const client = new TestClient( getDefaultTestClientOptions({ @@ -528,6 +530,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -543,6 +546,106 @@ describe('captureSpan', () => { consoleWarnSpy.mockRestore(); }); + + // Standalone spans (INP web vital spans) are streamed even with the static trace lifecycle, + // so a `withStaticSpan` callback has to see them in the v1 format it expects. + describe('with the static trace lifecycle', () => { + // Captures a child span so that ending it doesn't also send a transaction (which would apply the + // static callback a second time, via the transaction pipeline). + function captureStaticChildSpan( + beforeSendSpan: TestClientOptions['beforeSendSpan'], + attributes: Record = { 'sentry.op': 'http.client' }, + ): SerializedStreamedSpan { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendSpan, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + const rootSpan = startInactiveSpan({ name: 'root' }); + const span = startInactiveSpan({ name: 'my-span', attributes, parentSpan: rootSpan }); + span.end(); + return span; + }); + + return captureSpan(span, client); + } + + it('applies a static beforeSendSpan callback and hands it the v1 span format', () => { + const beforeSendSpan = vi.fn((span: SpanJSON) => span); + + captureStaticChildSpan(withStaticSpan(beforeSendSpan)); + + expect(beforeSendSpan).toHaveBeenCalledTimes(1); + expect(beforeSendSpan).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'my-span', + op: 'http.client', + data: expect.objectContaining({ 'sentry.op': 'http.client' }), + timestamp: expect.any(Number), + }), + ); + }); + + it('carries modifications from a static beforeSendSpan callback into the serialized span', () => { + const serialized = captureStaticChildSpan( + withStaticSpan((span: SpanJSON) => { + span.description = 'redacted'; + span.data['http.url'] = '[Filtered]'; + span.op = 'http.other'; + return span; + }), + { 'sentry.op': 'http.client', 'http.url': 'https://example.com/secret' }, + ); + + expect(serialized.name).toBe('redacted'); + expect(serialized.attributes['http.url']).toEqual({ type: 'string', value: '[Filtered]' }); + expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.other' }); + }); + + it('preserves span fields the callback leaves untouched', () => { + const serialized = captureStaticChildSpan(withStaticSpan((span: SpanJSON) => span)); + + expect(serialized).toEqual( + expect.objectContaining({ + name: 'my-span', + is_segment: false, + status: 'ok', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }), + ); + expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); + }); + + it("doesn't a default beforeSendSpan callback without the withStaticSpan wrapper", () => { + const beforeSendSpan = vi.fn(span => span); + + const serialized = captureStaticChildSpan(beforeSendSpan); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(serialized.name).toBe('my-span'); + }); + + it('keeps the span if a static beforeSendSpan callback returns null', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const serialized = captureStaticChildSpan(withStaticSpan(vi.fn(() => null as unknown as SpanJSON))); + + // The drop warning itself is only logged once per process, so it is asserted in the streamed test above. + expect(serialized.name).toBe('my-span'); + expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); + + consoleWarnSpy.mockRestore(); + }); + }); }); }); diff --git a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts index 94b25c590d19..22758f4879d0 100644 --- a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { SpanJSON } from '../../../../src/types/span'; -import { spanJsonToSerializedStreamedSpan } from '../../../../src/tracing/spans/spanJsonToStreamedSpan'; +import type { SpanJSON, StreamedSpanJSON } from '../../../../src/types/span'; +import { + spanJsonToSerializedStreamedSpan, + spanJsonToStreamedSpanJson, + streamedSpanJsonToSpanJson, +} from '../../../../src/tracing/spans/spanJsonToStreamedSpan'; function makeSpanJSON(overrides: Partial = {}): SpanJSON { return { @@ -90,4 +94,99 @@ describe('spanJsonToSerializedStreamedSpan', () => { { trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: { type: 'string', value: 'bar' } } }, ]); }); + + it('preserves is_segment', () => { + expect(spanJsonToStreamedSpanJson(makeSpanJSON({ is_segment: true })).is_segment).toBe(true); + expect(spanJsonToStreamedSpanJson(makeSpanJSON({ is_segment: undefined })).is_segment).toBe(false); + }); + + it('copies op and origin into attributes so edits to the v1 fields are kept', () => { + const result = spanJsonToStreamedSpanJson( + makeSpanJSON({ op: 'http.other', origin: 'manual.http', data: { 'sentry.op': 'http.client' } }), + ); + + expect(result.attributes?.['sentry.op']).toBe('http.other'); + expect(result.attributes?.['sentry.origin']).toBe('manual.http'); + }); +}); + +describe('streamedSpanJsonToSpanJson', () => { + function makeStreamedSpanJSON(overrides: Partial = {}): StreamedSpanJSON { + return { + span_id: 'abc123def456789a', + trace_id: '00112233445566778899aabbccddeeff', + name: 'GET /users', + start_timestamp: 1000, + end_timestamp: 1005, + status: 'ok', + is_segment: false, + attributes: { 'sentry.op': 'http.client', 'sentry.origin': 'auto.http', 'http.url': 'https://example.com' }, + ...overrides, + }; + } + + it('maps StreamedSpanJSON fields to the v1 SpanJSON fields', () => { + const result = streamedSpanJsonToSpanJson(makeStreamedSpanJSON({ parent_span_id: 'parent00deadbeef' })); + + expect(result).toEqual({ + span_id: 'abc123def456789a', + trace_id: '00112233445566778899aabbccddeeff', + parent_span_id: 'parent00deadbeef', + description: 'GET /users', + start_timestamp: 1000, + timestamp: 1005, + status: 'ok', + is_segment: false, + op: 'http.client', + origin: 'auto.http', + links: undefined, + data: { 'sentry.op': 'http.client', 'sentry.origin': 'auto.http', 'http.url': 'https://example.com' }, + }); + }); + + it('copies attributes rather than aliasing them', () => { + const streamedSpan = makeStreamedSpanJSON(); + const result = streamedSpanJsonToSpanJson(streamedSpan); + + result.data['http.url'] = '[Filtered]'; + + expect(streamedSpan.attributes?.['http.url']).toBe('https://example.com'); + }); + + it('handles a span without attributes', () => { + const result = streamedSpanJsonToSpanJson(makeStreamedSpanJSON({ attributes: undefined })); + + expect(result.data).toEqual({}); + expect(result.op).toBeUndefined(); + expect(result.origin).toBeUndefined(); + }); + + it.each([ + ['a segment span', true], + ['a child span', false], + ])('round-trips %s unchanged', (_, is_segment) => { + const streamedSpan = makeStreamedSpanJSON({ + is_segment, + status: 'error', + parent_span_id: 'parent00deadbeef', + links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }], + }); + + expect(spanJsonToStreamedSpanJson(streamedSpanJsonToSpanJson(streamedSpan))).toEqual(streamedSpan); + }); + + it('round-trips modifications made in the v1 format back into the streamed format', () => { + const streamedSpan = makeStreamedSpanJSON(); + + const spanJson = streamedSpanJsonToSpanJson(streamedSpan); + spanJson.description = 'redacted'; + spanJson.data['http.url'] = '[Filtered]'; + spanJson.op = 'http.other'; + + const result = spanJsonToStreamedSpanJson(spanJson); + + expect(result.name).toBe('redacted'); + expect(result.attributes?.['http.url']).toBe('[Filtered]'); + expect(result.attributes?.['sentry.op']).toBe('http.other'); + }); }); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 52a04b18aeb7..4ff878591009 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -97,6 +97,8 @@ export { wrapMcpServerWithSentry, featureFlagsIntegration, metrics, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, logger, consoleLoggingIntegration, diff --git a/packages/effect/src/index.types.ts b/packages/effect/src/index.types.ts index e1544da73485..bc77a68bd35d 100644 --- a/packages/effect/src/index.types.ts +++ b/packages/effect/src/index.types.ts @@ -22,6 +22,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 715c2484296e..74257f226a90 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -155,6 +155,8 @@ export { unleashIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, bunServerIntegration, makeFetchTransport, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index d07490563212..191c79a6756b 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -160,6 +160,8 @@ export { unleashIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index e0d918708bb1..42ef9d6a9502 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -27,6 +27,8 @@ export declare const consoleIntegration: typeof serverSdk.consoleIntegration; // Node-only at runtime; the edge build exports an inert shim so named imports resolve in edge-compiled modules. export declare const pinoIntegration: typeof serverSdk.pinoIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; // Different implementation in server and worker diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bf8dd5d7533e..bdc31ef809ab 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -174,7 +174,12 @@ export type { CaptureContext, } from '@sentry/core'; -export { metrics, withStreamedSpan } from '@sentry/core'; +export { + metrics, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated + withStreamedSpan, +} from '@sentry/core'; export * as logger from './logs/exports'; export { childProcessIntegration } from './integrations/childProcess'; diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index e058e0bfd099..4865aac868d3 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -17,6 +17,8 @@ export declare function init(options: Options | SentryNuxtClientOptions | Sentry export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index e7fb4382d0f2..af8c4da6d94a 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -17,6 +17,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const defaultStackParser: StackParser; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index ef2f89cee2a5..99c642506693 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -117,6 +117,8 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, featureFlagsIntegration, } from '@sentry/core'; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index 1a54068ae019..498b640d7e6e 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -19,6 +19,8 @@ export declare const browserTracingIntegration: typeof clientSdk.browserTracingI export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 4fd2331b34fa..88350f5e593d 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -133,6 +133,8 @@ export { createConsolaReporter, createSentryWinstonTransport, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index d984e1384a93..71e45f1d2a2d 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -19,6 +19,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index fd5fbb2e0e39..dc8640b965e8 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -132,6 +132,8 @@ export { createConsolaReporter, createSentryWinstonTransport, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 1f4bbae0b4a9..5404d001b205 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -48,6 +48,8 @@ export declare function wrapLoadWithSentry any>(orig export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; // Different implementation in server and worker diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 5ebd934e9e88..07a8147cefe8 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -137,6 +137,8 @@ export { vercelAIIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index de2146c9e259..65fe98529a5c 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -83,6 +83,8 @@ export { withIsolationScope, withMonitor, withScope, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, supabaseIntegration, instrumentSupabaseClient, diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index d6465a0e14d2..d1464a88175e 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -19,6 +19,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index d8e277f9fea0..29831c013835 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,6 +105,8 @@ export { featureFlagsIntegration, logger, metrics, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, } from '@sentry/core';