From e3f5e0482f21ba30a031ded3a9da73500fb44a78 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 2 Feb 2026 14:14:52 +0100 Subject: [PATCH 01/12] feat(core): Add span v2 and envelope type definitions (#19100) This PR introduces span v2 types as defined in our [develop spec](https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/): * Envelope types: * `SpanV2Envelope`, `SpanV2EnvelopeHeaders`, `SpanContainerItem`, `SpanContainerItemHeaders` * Span v2 types: * `SpanV2JSON` the equivalent to today's `SpanJSON`. Users will interact with spans in this format in `beforeSendSpan`. SDK integrations will use this format in `processSpan` (and related) hooks. * `SerializedSpan` the final, serialized format for v2 spans, sent in the envelope container item. Closes #19101 (added automatically) ref #17836 --- packages/core/src/index.ts | 4 +++ packages/core/src/types-hoist/envelope.ts | 21 ++++++++++++- packages/core/src/types-hoist/link.ts | 4 +-- packages/core/src/types-hoist/span.ts | 38 +++++++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61865ea7ba3c..ae1fcf561b9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -391,6 +391,7 @@ export type { ProfileChunkEnvelope, ProfileChunkItem, SpanEnvelope, + SpanV2Envelope, SpanItem, LogEnvelope, MetricEnvelope, @@ -458,6 +459,9 @@ export type { SpanJSON, SpanContextData, TraceFlag, + StreamedSpanJSON, + SerializedSpanContainer, + SerializedSpan, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; export type { Log, LogSeverityLevel } from './types-hoist/log'; diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 272f8cde9f62..7251f85b5df0 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -11,7 +11,7 @@ import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; -import type { SpanJSON } from './span'; +import type { SerializedSpanContainer, SpanJSON } from './span'; // Based on: https://develop.sentry.dev/sdk/envelopes/ @@ -91,6 +91,21 @@ type CheckInItemHeaders = { type: 'check_in' }; type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type SpanItemHeaders = { type: 'span' }; +type SpanContainerItemHeaders = { + /** + * Same as v1 span item type but this envelope is distinguished by {@link SpanContainerItemHeaders.content_type}. + */ + type: 'span'; + /** + * The number of span items in the container. This must be the same as the number of span items in the payload. + */ + item_count: number; + /** + * The content type of the span items. This must be `application/vnd.sentry.items.span.v2+json`. + * (the presence of this field also distinguishes the span item from the v1 span item) + */ + content_type: 'application/vnd.sentry.items.span.v2+json'; +}; type LogContainerItemHeaders = { type: 'log'; /** @@ -123,6 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; +export type SpanContainerItem = BaseEnvelopeItem; export type LogContainerItem = BaseEnvelopeItem; export type MetricContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; @@ -133,6 +149,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type SpanV2EnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; type LogEnvelopeHeaders = BaseEnvelopeHeaders; type MetricEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< @@ -144,6 +161,7 @@ export type ClientReportEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; +export type SpanV2Envelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -157,6 +175,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope + | SpanV2Envelope | RawSecurityEnvelope | LogEnvelope | MetricEnvelope; diff --git a/packages/core/src/types-hoist/link.ts b/packages/core/src/types-hoist/link.ts index a330dc108b00..9a117258200b 100644 --- a/packages/core/src/types-hoist/link.ts +++ b/packages/core/src/types-hoist/link.ts @@ -22,9 +22,9 @@ export interface SpanLink { * Link interface for the event envelope item. It's a flattened representation of `SpanLink`. * Can include additional fields defined by OTel. */ -export interface SpanLinkJSON extends Record { +export interface SpanLinkJSON extends Record { span_id: string; trace_id: string; sampled?: boolean; - attributes?: SpanLinkAttributes; + attributes?: TAttributes; } diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index d82463768b7f..8b7ea3e02275 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,3 +1,4 @@ +import type { Attributes, RawAttributes } from '../attributes'; import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; @@ -34,6 +35,43 @@ export type SpanAttributes = Partial<{ /** This type is aligned with the OpenTelemetry TimeInput type. */ export type SpanTimeInput = HrTime | number | Date; +/** + * Intermediate JSON reporesentation of a v2 span, which users and our SDK integrations will interact with. + * This is NOT the final serialized JSON span, but an intermediate step still holding raw attributes. + * The final, serialized span is a {@link SerializedSpan}. + * Main reason: Make it easier and safer for users to work with attributes. + */ +export interface StreamedSpanJSON { + trace_id: string; + parent_span_id?: string; + span_id: string; + name: string; + start_timestamp: number; + end_timestamp: number; + status: 'ok' | 'error'; + is_segment: boolean; + attributes?: RawAttributes>; + links?: SpanLinkJSON>>[]; +} + +/** + * Serialized span item. + * This is the final, serialized span format that is sent to Sentry. + * The intermediate representation is {@link StreamedSpanJSON}. + * Main difference: Attributes are converted to {@link Attributes}, thus including the `type` annotation. + */ +export type SerializedSpan = Omit & { + attributes?: Attributes; + links?: SpanLinkJSON[]; +}; + +/** + * Envelope span item container. + */ +export type SerializedSpanContainer = { + items: Array; +}; + /** A JSON representation of a span. */ export interface SpanJSON { data: SpanAttributes; From 816a969489961ce69a57ca48f3b2610d2f06d802 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 4 Feb 2026 14:25:30 +0100 Subject: [PATCH 02/12] feat(core): Add `traceLifecycle` option and `beforeSendSpan` compatibility utilities (#19120) This adds the foundation for user-facing span streaming configuration: - **`traceLifecycle` option**: New option in `ClientOptions` that controls whether spans are sent statically (when the entire local span tree is complete) or streamed (in batches following interval- and action-based triggers). Because the span JSON will look different for streamed spans vs. static spans (i.e. our current ones, we also need some helpers for `beforeSendSpan` where users consume and interact with `StreamedSpanJSON`: - **`withStreamedSpan()` utility**: Wrapper function that marks a `beforeSendSpan` callback as compatible with the streamed span format (`StreamedSpanJSON`) - **`isStreamedBeforeSendSpanCallback()` type guard**: Internal utility to check if a callback was wrapped with `withStreamedSpan` --- .size-limit.js | 2 +- packages/core/src/client.ts | 5 ++- packages/core/src/envelope.ts | 3 +- packages/core/src/index.ts | 1 + packages/core/src/types-hoist/options.ts | 28 ++++++++++++- packages/core/src/utils/beforeSendSpan.ts | 41 +++++++++++++++++++ .../test/lib/utils/beforeSendSpan.test.ts | 26 ++++++++++++ 7 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/utils/beforeSendSpan.ts create mode 100644 packages/core/test/lib/utils/beforeSendSpan.test.ts diff --git a/.size-limit.js b/.size-limit.js index baded21f5200..35681660e4f2 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -103,7 +103,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '31 KB', + limit: '32 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8d69411aacfd..9b6d71780cfd 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -34,6 +34,7 @@ import type { SeverityLevel } from './types-hoist/severity'; import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; +import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; import { createClientReportEnvelope } from './utils/clientreport'; import { debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; @@ -1513,7 +1514,9 @@ function processBeforeSend( event: Event, hint: EventHint, ): PromiseLike | Event | null { - const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options; + const { beforeSend, beforeSendTransaction, ignoreSpans } = options; + const beforeSendSpan = !isStreamedBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; + let processedEvent = event; if (isErrorEvent(processedEvent) && beforeSend) { diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 875056890e0e..c7a46359260f 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -18,6 +18,7 @@ import type { Event } from './types-hoist/event'; import type { SdkInfo } from './types-hoist/sdkinfo'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; import type { Session, SessionAggregates } from './types-hoist/session'; +import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; import { dsnToString } from './utils/dsn'; import { createEnvelope, @@ -152,7 +153,7 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? const convertToSpanJSON = beforeSendSpan ? (span: SentrySpan) => { const spanJson = spanToJSON(span); - const processedSpan = beforeSendSpan(spanJson); + const processedSpan = !isStreamedBeforeSendSpanCallback(beforeSendSpan) ? beforeSendSpan(spanJson) : spanJson; if (!processedSpan) { showSpanDropWarning(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae1fcf561b9a..5766823da132 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,7 @@ export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; +export { withStreamedSpan } from './utils/beforeSendSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 92292f8e6e3d..63310a66c3d2 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -6,7 +6,7 @@ import type { Log } from './log'; import type { Metric } from './metric'; import type { TracesSamplerSamplingContext } from './samplingcontext'; import type { SdkMetadata } from './sdkmetadata'; -import type { SpanJSON } from './span'; +import type { SpanJSON, StreamedSpanJSON } from './span'; import type { StackLineParser, StackParser } from './stacktrace'; import type { TracePropagationTargets } from './tracing'; import type { BaseTransportOptions, Transport } from './transport'; @@ -500,6 +500,14 @@ export interface ClientOptions SpanJSON; + beforeSendSpan?: ((span: SpanJSON) => SpanJSON) | BeforeSendStramedSpanCallback; /** * An event-processing callback for transaction events, guaranteed to be invoked after all other event @@ -615,6 +626,19 @@ export interface ClientOptions Breadcrumb | null; } +/** + * A callback for processing streamed spans before they are sent. + * + * @see {@link StreamedSpanJSON} for the streamed span format used with `traceLifecycle: 'stream'` + */ +export type BeforeSendStramedSpanCallback = ((span: StreamedSpanJSON) => 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; +}; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/beforeSendSpan.ts b/packages/core/src/utils/beforeSendSpan.ts new file mode 100644 index 000000000000..68c4576d179d --- /dev/null +++ b/packages/core/src/utils/beforeSendSpan.ts @@ -0,0 +1,41 @@ +import type { BeforeSendStramedSpanCallback, ClientOptions } from '../types-hoist/options'; +import type { StreamedSpanJSON } from '../types-hoist/span'; +import { addNonEnumerableProperty } from './object'; + +/** + * A wrapper to use the new 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}. + * + * @example + * + * Sentry.init({ + * traceLifecycle: 'stream', + * beforeSendSpan: withStreamedSpan((span) => { + * // span is of type StreamedSpanJSON + * return span; + * }), + * }); + * + * @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'`. + */ +export function withStreamedSpan( + callback: (span: StreamedSpanJSON) => StreamedSpanJSON, +): BeforeSendStramedSpanCallback { + addNonEnumerableProperty(callback, '_streamed', true); + return callback; +} + +/** + * 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}. + */ +export function isStreamedBeforeSendSpanCallback( + callback: ClientOptions['beforeSendSpan'], +): callback is BeforeSendStramedSpanCallback { + return !!callback && '_streamed' in callback && !!callback._streamed; +} diff --git a/packages/core/test/lib/utils/beforeSendSpan.test.ts b/packages/core/test/lib/utils/beforeSendSpan.test.ts new file mode 100644 index 000000000000..5e5bdc566889 --- /dev/null +++ b/packages/core/test/lib/utils/beforeSendSpan.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { withStreamedSpan } from '../../../src'; +import { isStreamedBeforeSendSpanCallback } from '../../../src/utils/beforeSendSpan'; + +describe('beforeSendSpan for span streaming', () => { + describe('withStreamedSpan', () => { + it('should be able to modify the span', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStreamedSpan(beforeSendSpan); + expect(wrapped._streamed).toBe(true); + }); + }); + + describe('isStreamedBeforeSendSpanCallback', () => { + it('returns true if the callback is wrapped with withStreamedSpan', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStreamedSpan(beforeSendSpan); + expect(isStreamedBeforeSendSpanCallback(wrapped)).toBe(true); + }); + + it('returns false if the callback is not wrapped with withStreamedSpan', () => { + const beforeSendSpan = vi.fn(); + expect(isStreamedBeforeSendSpanCallback(beforeSendSpan)).toBe(false); + }); + }); +}); From d3fee95d02ccdbb0c38d6c6304cf69d6c9e9a4a5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 4 Feb 2026 17:00:17 +0100 Subject: [PATCH 03/12] feat(core): Add `StreamedSpanEnvelope` creation function (#19153) Adds a utility to create a span v2 envelope from a `SerializedSpan` array + tests. Note: I think here, the "v2" naming makes more sense than the `StreamSpan` patter we use for user-facing functionality. This function should never be called by users, and the envelope is type `span` with content type `span.v2+json` ref #17836 --- packages/core/src/index.ts | 4 +- packages/core/src/tracing/spans/README.md | 2 + packages/core/src/tracing/spans/envelope.ts | 36 +++ packages/core/src/types-hoist/envelope.ts | 10 +- packages/core/src/types-hoist/span.ts | 8 +- .../test/lib/tracing/spans/envelope.test.ts | 232 ++++++++++++++++++ 6 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/tracing/spans/README.md create mode 100644 packages/core/src/tracing/spans/envelope.ts create mode 100644 packages/core/test/lib/tracing/spans/envelope.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5766823da132..d0998e0ecf4b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -392,7 +392,7 @@ export type { ProfileChunkEnvelope, ProfileChunkItem, SpanEnvelope, - SpanV2Envelope, + StreamedSpanEnvelope, SpanItem, LogEnvelope, MetricEnvelope, @@ -461,8 +461,6 @@ export type { SpanContextData, TraceFlag, StreamedSpanJSON, - SerializedSpanContainer, - SerializedSpan, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; export type { Log, LogSeverityLevel } from './types-hoist/log'; diff --git a/packages/core/src/tracing/spans/README.md b/packages/core/src/tracing/spans/README.md new file mode 100644 index 000000000000..fa40e8a0ff11 --- /dev/null +++ b/packages/core/src/tracing/spans/README.md @@ -0,0 +1,2 @@ +For now, all span streaming related tracing code is in this sub directory. +Once we get rid of transaction-based tracing, we can clean up and flatten the entire tracing directory. diff --git a/packages/core/src/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts new file mode 100644 index 000000000000..8429b22d7e1c --- /dev/null +++ b/packages/core/src/tracing/spans/envelope.ts @@ -0,0 +1,36 @@ +import type { Client } from '../../client'; +import type { DynamicSamplingContext, SpanContainerItem, StreamedSpanEnvelope } from '../../types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; +import { dsnToString } from '../../utils/dsn'; +import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope'; + +/** + * Creates a span v2 span streaming envelope + */ +export function createStreamedSpanEnvelope( + serializedSpans: Array, + dsc: Partial, + client: Client, +): StreamedSpanEnvelope { + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + const sdk = getSdkMetadataForEnvelopeHeader(client.getOptions()._metadata); + + const headers: StreamedSpanEnvelope[0] = { + sent_at: new Date().toISOString(), + ...(dscHasRequiredProps(dsc) && { trace: dsc }), + ...(sdk && { sdk }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; + + const spanContainer: SpanContainerItem = [ + { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: serializedSpans }, + ]; + + return createEnvelope(headers, [spanContainer]); +} + +function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { + return !!dsc.trace_id && !!dsc.public_key; +} diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 7251f85b5df0..d8b8a1822b04 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -11,7 +11,7 @@ import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; -import type { SerializedSpanContainer, SpanJSON } from './span'; +import type { SerializedStreamedSpanContainer, SpanJSON } from './span'; // Based on: https://develop.sentry.dev/sdk/envelopes/ @@ -138,7 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; -export type SpanContainerItem = BaseEnvelopeItem; +export type SpanContainerItem = BaseEnvelopeItem; export type LogContainerItem = BaseEnvelopeItem; export type MetricContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; @@ -149,7 +149,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; -type SpanV2EnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type StreamedSpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; type LogEnvelopeHeaders = BaseEnvelopeHeaders; type MetricEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< @@ -161,7 +161,7 @@ export type ClientReportEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; -export type SpanV2Envelope = BaseEnvelope; +export type StreamedSpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -175,7 +175,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope - | SpanV2Envelope + | StreamedSpanEnvelope | RawSecurityEnvelope | LogEnvelope | MetricEnvelope; diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index 8b7ea3e02275..a918cc57859c 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -38,7 +38,7 @@ export type SpanTimeInput = HrTime | number | Date; /** * Intermediate JSON reporesentation of a v2 span, which users and our SDK integrations will interact with. * This is NOT the final serialized JSON span, but an intermediate step still holding raw attributes. - * The final, serialized span is a {@link SerializedSpan}. + * The final, serialized span is a {@link SerializedStreamedSpan}. * Main reason: Make it easier and safer for users to work with attributes. */ export interface StreamedSpanJSON { @@ -60,7 +60,7 @@ export interface StreamedSpanJSON { * The intermediate representation is {@link StreamedSpanJSON}. * Main difference: Attributes are converted to {@link Attributes}, thus including the `type` annotation. */ -export type SerializedSpan = Omit & { +export type SerializedStreamedSpan = Omit & { attributes?: Attributes; links?: SpanLinkJSON[]; }; @@ -68,8 +68,8 @@ export type SerializedSpan = Omit & { /** * Envelope span item container. */ -export type SerializedSpanContainer = { - items: Array; +export type SerializedStreamedSpanContainer = { + items: Array; }; /** A JSON representation of a span. */ diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts new file mode 100644 index 000000000000..197b7ed40365 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/envelope.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import { createStreamedSpanEnvelope } from '../../../../src/tracing/spans/envelope'; +import type { DynamicSamplingContext } from '../../../../src/types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function createMockSerializedSpan(overrides: Partial = {}): SerializedStreamedSpan { + return { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: 1713859200, + end_timestamp: 1713859201, + status: 'ok', + is_segment: false, + ...overrides, + }; +} + +describe('createStreamedSpanEnvelope', () => { + describe('envelope headers', () => { + it('creates an envelope with sent_at header', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sent_at', expect.any(String)); + }); + + it('includes trace header when DSC has required props (trace_id and public_key)', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + sample_rate: '1.0', + release: 'v1.0.0', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('trace', dsc); + }); + + it("does't include trace header when DSC is missing trace_id", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + public_key: 'public-key-abc', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it("does't include trace header when DSC is missing public_key", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + trace_id: 'trace-123', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it('includes SDK info when available in client options', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + _metadata: { + sdk: { name: 'sentry.javascript.browser', version: '8.0.0' }, + }, + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sdk', { name: 'sentry.javascript.browser', version: '8.0.0' }); + }); + + it("does't include SDK info when not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('sdk'); + }); + + it('includes DSN when tunnel and DSN are configured', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('dsn', 'https://abc123@example.sentry.io/456'); + }); + + it("does't include DSN when tunnel is not configured", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it("does't include DSN when DSN is not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it('includes all headers when all options are provided', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + _metadata: { + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + }, + }), + ); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + environment: 'production', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toEqual({ + sent_at: expect.any(String), + trace: dsc, + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + dsn: 'https://abc123@example.sentry.io/456', + }); + }); + }); + + describe('envelope item', () => { + it('creates a span container item with correct structure', () => { + const mockSpan = createMockSerializedSpan({ name: 'span-1' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 1, + type: 'span', + }, + { + items: [mockSpan], + }, + ], + ]); + }); + + it('sets correct item_count for multiple spans', () => { + const mockSpan1 = createMockSerializedSpan({ span_id: 'span-1' }); + const mockSpan2 = createMockSerializedSpan({ span_id: 'span-2' }); + const mockSpan3 = createMockSerializedSpan({ span_id: 'span-3' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan1, mockSpan2, mockSpan3], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 3, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: [mockSpan1, mockSpan2, mockSpan3] }, + ], + ]); + }); + + it('handles empty spans array', () => { + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([], dsc, mockClient); + + expect(result).toEqual([ + { + sent_at: expect.any(String), + }, + [ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 0, + type: 'span', + }, + { + items: [], + }, + ], + ], + ]); + }); + }); +}); From f3ce59af5efad905c1696c5c5b158846040760bd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 4 Feb 2026 17:00:50 +0100 Subject: [PATCH 04/12] feat(core): Add span serialization utilities (#19140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds span JSON conversion and serialization helpers for span streaming: * `spanToStreamedSpanJSON`: Converts a `Span` instance to a JSON object used as intermediate representation as outlined in https://github.com/getsentry/sentry-javascript/pull/19100 * Adds `SentrySpan::getStreamedSpanJSON` method to convert our own spans * Directly converts any OTel spans * This is analogous to how `spanToJSON` works today. * `spanJsonToSerializedSpan`: Converts a `StreamedSpanJSON` into the final `SerializedSpan` to be sent to Sentry. This PR also adds unit tests for both helpers. ref #17836 --------- Co-authored-by: Cursor Co-authored-by: Jan Peer Stöcklmair --- packages/core/src/index.ts | 2 + packages/core/src/tracing/sentrySpan.ts | 28 +++ packages/core/src/utils/spanUtils.ts | 131 ++++++++-- .../core/test/lib/utils/spanUtils.test.ts | 235 +++++++++++++++++- 4 files changed, 381 insertions(+), 15 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0998e0ecf4b..4f6f2e864b25 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -82,11 +82,13 @@ export { convertSpanLinksForEnvelope, spanToTraceHeader, spanToJSON, + spanToStreamedSpanJSON, spanIsSampled, spanToTraceContext, getSpanDescendants, getStatusMessage, getRootSpan, + INTERNAL_getSegmentSpan, getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 9bd98b9741c6..8bdae7129dba 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { createSpanEnvelope } from '../envelope'; @@ -21,6 +22,7 @@ import type { SpanJSON, SpanOrigin, SpanTimeInput, + StreamedSpanJSON, } from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import type { TimedEvent } from '../types-hoist/timedEvent'; @@ -29,8 +31,10 @@ import { generateSpanId, generateTraceId } from '../utils/propagationContext'; import { convertSpanLinksForEnvelope, getRootSpan, + getSimpleStatusMessage, getSpanDescendants, getStatusMessage, + getStreamedSpanLinks, spanTimeInputToSeconds, spanToJSON, spanToTransactionTraceContext, @@ -241,6 +245,30 @@ export class SentrySpan implements Span { }; } + /** + * Get {@link StreamedSpanJSON} representation of this span. + * + * @hidden + * @internal This method is purely for internal purposes and should not be used outside + * of SDK code. If you need to get a JSON representation of a span, + * use `spanToStreamedSpanJSON(span)` instead. + */ + public getStreamedSpanJSON(): StreamedSpanJSON { + return { + name: this._name ?? '', + span_id: this._spanId, + trace_id: this._traceId, + parent_span_id: this._parentSpanId, + start_timestamp: this._startTime, + // just in case _endTime is not set, we use the start time (i.e. duration 0) + end_timestamp: this._endTime ?? this._startTime, + is_segment: this._isStandaloneSpan || this === getRootSpan(this), + status: getSimpleStatusMessage(this._status), + attributes: this._attributes, + links: getStreamedSpanLinks(this._links), + }; + } + /** @inheritdoc */ public isRecording(): boolean { return !this._endTime && !!this._sampled; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index d7c261ecd73c..6e4a95b61d7b 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -1,4 +1,6 @@ import { getAsyncContextStrategy } from '../asyncContext'; +import type { RawAttributes } from '../attributes'; +import { serializeAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import { @@ -12,7 +14,15 @@ import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; -import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput } from '../types-hoist/span'; +import type { + SerializedSpan, + Span, + SpanAttributes, + SpanJSON, + SpanOrigin, + SpanTimeInput, + StreamedSpanJSON, +} from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; @@ -105,6 +115,27 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] } } +/** + * Converts the span links array to a flattened version with serialized attributes for V2 spans. + * + * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent. + */ +export function getStreamedSpanLinks( + links?: SpanLink[], +): SpanLinkJSON>>[] | undefined { + if (links?.length) { + return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ + span_id: spanId, + trace_id: traceId, + sampled: traceFlags === TRACE_FLAG_SAMPLED, + attributes, + ...restContext, + })); + } else { + return undefined; + } +} + /** * Convert a span time input into a timestamp in seconds. */ @@ -150,23 +181,12 @@ export function spanToJSON(span: Span): SpanJSON { if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { const { attributes, startTime, name, endTime, status, links } = span; - // In preparation for the next major of OpenTelemetry, we want to support - // looking up the parent span id according to the new API - // In OTel v1, the parent span id is accessed as `parentSpanId` - // In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` - const parentSpanId = - 'parentSpanId' in span - ? span.parentSpanId - : 'parentSpanContext' in span - ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId - : undefined; - return { span_id, trace_id, data: attributes, description: name, - parent_span_id: parentSpanId, + parent_span_id: getOtelParentSpanId(span), start_timestamp: spanTimeInputToSeconds(startTime), // This is [0,0] by default in OTEL, in which case we want to interpret this as no end time timestamp: spanTimeInputToSeconds(endTime) || undefined, @@ -187,6 +207,77 @@ export function spanToJSON(span: Span): SpanJSON { }; } +/** + * Convert a span to the intermediate {@link StreamedSpanJSON} representation. + */ +export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON { + if (spanIsSentrySpan(span)) { + return span.getStreamedSpanJSON(); + } + + const { spanId: span_id, traceId: trace_id } = span.spanContext(); + + // Handle a span from @opentelemetry/sdk-base-trace's `Span` class + if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { + const { attributes, startTime, name, endTime, status, links } = span; + + return { + name, + span_id, + trace_id, + parent_span_id: getOtelParentSpanId(span), + start_timestamp: spanTimeInputToSeconds(startTime), + end_timestamp: spanTimeInputToSeconds(endTime), + is_segment: span === INTERNAL_getSegmentSpan(span), + status: getSimpleStatusMessage(status), + attributes, + links: getStreamedSpanLinks(links), + }; + } + + // Finally, as a fallback, at least we have `spanContext()`.... + // This should not actually happen in reality, but we need to handle it for type safety. + return { + span_id, + trace_id, + start_timestamp: 0, + name: '', + end_timestamp: 0, + status: 'ok', + is_segment: span === INTERNAL_getSegmentSpan(span), + }; +} + +/** + * In preparation for the next major of OpenTelemetry, we want to support + * looking up the parent span id according to the new API + * In OTel v1, the parent span id is accessed as `parentSpanId` + * In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + */ +function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | undefined { + return 'parentSpanId' in span + ? span.parentSpanId + : 'parentSpanContext' in span + ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId + : undefined; +} + +/** + * Converts a {@link StreamedSpanJSON} to a {@link SerializedSpan}. + * This is the final serialized span format that is sent to Sentry. + * The returned serilaized spans must not be consumed by users or SDK integrations. + */ +export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedSpan { + return { + ...spanJson, + attributes: serializeAttributes(spanJson.attributes), + links: spanJson.links?.map(link => ({ + ...link, + attributes: serializeAttributes(link.attributes), + })), + }; +} + function spanIsOpenTelemetrySdkTraceBaseSpan(span: Span): span is OpenTelemetrySdkTraceBaseSpan { const castSpan = span as Partial; return !!castSpan.attributes && !!castSpan.startTime && !!castSpan.name && !!castSpan.endTime && !!castSpan.status; @@ -237,6 +328,13 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef return status.message || 'internal_error'; } +/** + * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default). + */ +export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { + return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error'; +} + const CHILD_SPANS_FIELD = '_sentryChildSpans'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; @@ -298,7 +396,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] { /** * Returns the root span of a given span. */ -export function getRootSpan(span: SpanWithPotentialChildren): Span { +export const getRootSpan = INTERNAL_getSegmentSpan; + +/** + * Returns the segment span of a given span. + */ +export function INTERNAL_getSegmentSpan(span: SpanWithPotentialChildren): Span { return span[ROOT_SPAN_FIELD] || span; } diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index bca9a406dd50..e4a0b31990d7 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -4,6 +4,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, SentrySpan, setCurrentClient, SPAN_STATUS_ERROR, @@ -16,7 +17,7 @@ import { TRACEPARENT_REGEXP, } from '../../../src'; import type { SpanLink } from '../../../src/types-hoist/link'; -import type { Span, SpanAttributes, SpanTimeInput } from '../../../src/types-hoist/span'; +import type { Span, SpanAttributes, SpanTimeInput, StreamedSpanJSON } from '../../../src/types-hoist/span'; import type { SpanStatus } from '../../../src/types-hoist/spanStatus'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; import { @@ -24,7 +25,9 @@ import { spanIsSampled, spanTimeInputToSeconds, spanToJSON, + spanToStreamedSpanJSON, spanToTraceContext, + streamedSpanJsonToSerializedSpan, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, updateSpanName, @@ -41,6 +44,7 @@ function createMockedOtelSpan({ status = { code: SPAN_STATUS_UNSET }, endTime = Date.now(), parentSpanId, + links = undefined, }: { spanId: string; traceId: string; @@ -51,6 +55,7 @@ function createMockedOtelSpan({ status?: SpanStatus; endTime?: SpanTimeInput; parentSpanId?: string; + links?: SpanLink[]; }): Span { return { spanContext: () => { @@ -66,6 +71,7 @@ function createMockedOtelSpan({ status, endTime, parentSpanId, + links, } as OpenTelemetrySdkTraceBaseSpan; } @@ -409,6 +415,233 @@ describe('spanToJSON', () => { }); }); + describe('spanToStreamedSpanJSON', () => { + describe('SentrySpan', () => { + it('converts a minimal span', () => { + const span = new SentrySpan(); + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + name: '', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + }, + }); + }); + + it('converts a full span', () => { + const span = new SentrySpan({ + op: 'test op', + name: 'test name', + parentSpanId: '1234', + spanId: '5678', + traceId: 'abcd', + startTimestamp: 123, + endTimestamp: 456, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + attr1: 'value1', + attr2: 2, + attr3: true, + }, + links: [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + ], + }); + span.setStatus({ code: SPAN_STATUS_OK }); + span.setAttribute('attr4', [1, 2, 3]); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: 'value1', + attr2: 2, + attr3: true, + attr4: [1, 2, 3], + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }); + }); + }); + describe('OpenTelemetry Span', () => { + it('converts a simple span', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + name: 'test span', + startTime: 123, + endTime: [0, 0], + attributes: {}, + status: { code: SPAN_STATUS_UNSET }, + }); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: 'SPAN-1', + trace_id: 'TRACE-1', + parent_span_id: undefined, + start_timestamp: 123, + end_timestamp: 0, + name: 'test span', + is_segment: true, + status: 'ok', + attributes: {}, + }); + }); + + it('converts a full span', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + parentSpanId: 'PARENT-1', + name: 'test span', + startTime: 123, + endTime: 456, + attributes: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + status: { code: SPAN_STATUS_ERROR, message: 'unknown_error' }, + }); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: 'SPAN-1', + trace_id: 'TRACE-1', + parent_span_id: 'PARENT-1', + start_timestamp: 123, + end_timestamp: 456, + name: 'test span', + is_segment: true, + status: 'error', + attributes: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }); + }); + }); + }); + + describe('streamedSpanJsonToSerializedSpan', () => { + it('converts a streamed span JSON with links to a serialized span', () => { + const spanJson: StreamedSpanJSON = { + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: 'value1', + attr2: 2, + attr3: true, + attr4: [1, 2, 3], + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }; + + expect(streamedSpanJsonToSerializedSpan(spanJson)).toEqual({ + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: { type: 'string', value: 'value1' }, + attr2: { type: 'integer', value: 2 }, + attr3: { type: 'boolean', value: true }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test op' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto' }, + // notice the absence of `attr4`! + // for now, we don't yet serialize array attributes. This test will fail + // once we allow serializing them. + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { type: 'string', value: 'previous_trace' }, + }, + }, + ], + }); + }); + }); + it('returns minimal object for unknown span implementation', () => { const span = { // This is the minimal interface we require from a span From ff83beafcb60766bb01d06fae64cefab61327506 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 15:08:14 +0100 Subject: [PATCH 05/12] feat(core): Add `captureSpan` pipeline and helpers (#19197) This PR adds the `captureSpan` pipeline, which takes a `Span` instance, processes it and ultimately returns a `SerializedStreamedSpan` which can then be enqueued into the span buffer. ref #17836 --- packages/core/src/client.ts | 22 +- packages/core/src/semanticAttributes.ts | 28 +- packages/core/src/tracing/index.ts | 3 + .../core/src/tracing/spans/captureSpan.ts | 150 ++++++ packages/core/src/utils/spanUtils.ts | 4 +- .../lib/tracing/spans/captureSpan.test.ts | 485 ++++++++++++++++++ 6 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/tracing/spans/captureSpan.ts create mode 100644 packages/core/test/lib/tracing/spans/captureSpan.test.ts diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 9b6d71780cfd..ccb52d0e83ff 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -31,7 +31,7 @@ import type { RequestEventData } from './types-hoist/request'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; import type { Session, SessionAggregates } from './types-hoist/session'; import type { SeverityLevel } from './types-hoist/severity'; -import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span'; +import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; @@ -614,6 +614,16 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for when a span JSON is processed, to add some data to the span JSON. + */ + public on(hook: 'processSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void; + + /** + * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON. + */ + public on(hook: 'processSegmentSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void; + /** * Register a callback for when an idle span is allowed to auto-finish. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -886,6 +896,16 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; + /** + * Register a callback for when a span JSON is processed, to add some data to the span JSON. + */ + public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void; + + /** + * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON. + */ + public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void; + /** * Fire a hook indicating that an idle span is allowed to auto finish. */ diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 88b0f470dfa3..02b6a4ec08a6 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -1,7 +1,7 @@ /** - * Use this attribute to represent the source of a span. - * Should be one of: custom, url, route, view, component, task, unknown - * + * Use this attribute to represent the source of a span name. + * Must be one of: custom, url, route, view, component, task + * TODO(v11): rename this to sentry.span.source' */ export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source'; @@ -40,6 +40,28 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; +/** The release version of the application */ +export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; +/** The environment name (e.g., "production", "staging", "development") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment'; +/** The segment name (e.g., "GET /users") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment.name'; +/** The id of the segment that this span belongs to. */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID = 'sentry.segment.id'; +/** The name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; +/** The version of the Sentry SDK */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; + +/** The user ID (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; +/** The user email (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; +/** The user IP address (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; +/** The user username (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; + /** * A custom span name set by users guaranteed to be taken over any automatically * inferred name. This attribute is removed before the span is sent. diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 9997cab3519b..3d3736876015 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -23,3 +23,6 @@ export { export { setMeasurement, timedEventsToMeasurements } from './measurement'; export { sampleSpan } from './sampling'; export { logSpanEnd, logSpanStart } from './logSpans'; + +// Span Streaming +export { captureSpan } from './spans/captureSpan'; diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts new file mode 100644 index 000000000000..b332f3339dba --- /dev/null +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -0,0 +1,150 @@ +import type { RawAttributes } from '../../attributes'; +import type { Client } from '../../client'; +import type { ScopeData } from '../../scope'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, +} from '../../semanticAttributes'; +import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; +import { isStreamedBeforeSendSpanCallback } from '../../utils/beforeSendSpan'; +import { getCombinedScopeData } from '../../utils/scopeData'; +import { + INTERNAL_getSegmentSpan, + showSpanDropWarning, + spanToStreamedSpanJSON, + streamedSpanJsonToSerializedSpan, +} from '../../utils/spanUtils'; +import { getCapturedScopesOnSpan } from '../utils'; + +type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { + _segmentSpan: Span; +}; + +/** + * Captures a span and returns a JSON representation to be enqueued for sending. + * + * IMPORTANT: This function converts the span to JSON immediately to avoid writing + * to an already-ended OTel span instance (which is blocked by the OTel Span class). + * + * @returns the final serialized span with a reference to its segment span. This reference + * is needed later on to compute the DSC for the span envelope. + */ +export function captureSpan(span: Span, client: Client): SerializedStreamedSpanWithSegmentSpan { + // Convert to JSON FIRST - we cannot write to an already-ended span + const spanJSON = spanToStreamedSpanJSON(span); + + const segmentSpan = INTERNAL_getSegmentSpan(span); + const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + + const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope); + + applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); + + if (span === segmentSpan) { + applyScopeToSegmentSpan(spanJSON, finalScopeData); + // Allow hook subscribers to mutate the segment span JSON + client.emit('processSegmentSpan', spanJSON); + } + + // Allow hook subscribers to mutate the span JSON + client.emit('processSpan', spanJSON); + + const { beforeSendSpan } = client.getOptions(); + const processedSpan = + beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan) + ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) + : spanJSON; + + // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. + // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source + const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + if (spanNameSource) { + safeSetSpanJSONAttributes(processedSpan, { + // Purposefully not using a constant defined here like in other attributes: + // This will be the name for SEMANTIC_ATTRIBUTE_SENTRY_SOURCE in v11 + 'sentry.span.source': spanNameSource, + }); + } + + return { + ...streamedSpanJsonToSerializedSpan(processedSpan), + _segmentSpan: segmentSpan, + }; +} + +function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { + // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span + // This will follow in a separate PR +} + +function applyCommonSpanAttributes( + spanJSON: StreamedSpanJSON, + serializedSegmentSpan: StreamedSpanJSON, + client: Client, + scopeData: ScopeData, +): void { + const sdk = client.getSdkMetadata(); + const { release, environment, sendDefaultPii } = client.getOptions(); + + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, + ...(sendDefaultPii + ? { + [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + } + : {}), + ...scopeData.attributes, + }); +} + +/** + * Apply a user-provided beforeSendSpan callback to a span JSON. + */ +export function applyBeforeSendSpanCallback( + span: StreamedSpanJSON, + beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON, +): StreamedSpanJSON { + const modifedSpan = beforeSendSpan(span); + if (!modifedSpan) { + showSpanDropWarning(); + return span; + } + return modifedSpan; +} + +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { + originalAttributes[key] = value; + } + }); +} diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 6e4a95b61d7b..2168530a9c91 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -15,7 +15,7 @@ import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; import type { - SerializedSpan, + SerializedStreamedSpan, Span, SpanAttributes, SpanJSON, @@ -267,7 +267,7 @@ function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | unde * This is the final serialized span format that is sent to Sentry. * The returned serilaized spans must not be consumed by users or SDK integrations. */ -export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedSpan { +export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedStreamedSpan { return { ...spanJson, attributes: serializeAttributes(spanJson.attributes), diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts new file mode 100644 index 000000000000..d429d50714a2 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { StreamedSpanJSON } from '../../../../src'; +import { + captureSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, + startInactiveSpan, + startSpan, + withScope, + withStreamedSpan, +} from '../../../../src'; +import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('captureSpan', () => { + it('captures user attributes iff sendDefaultPii is true', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + const serializedSpan = captureSpan(span, client); + + expect(serializedSpan).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it.each([false, undefined])("doesn't capture user attributes if sendDefaultPii is %s", sendDefaultPii => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it('captures sdk name and version if available', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + _metadata: { + sdk: { + name: 'sentry.javascript.node', + version: '1.0.0', + integrations: ['UnhandledRejection', 'Dedupe'], + }, + }, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + value: 'sentry.javascript.node', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + value: '1.0.0', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + describe('client hooks', () => { + it('calls processSpan and processSegmentSpan hooks for a segment span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + + captureSpan(span, client); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); + expect(processSegmentSpanFn).toHaveBeenCalledWith( + expect.objectContaining({ span_id: span.spanContext().spanId }), + ); + }); + + it('only calls processSpan hook for a child span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const serializedChildSpan = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + return startSpan({ name: 'segment' }, () => { + const childSpan = startInactiveSpan({ name: 'child' }); + childSpan.end(); + return captureSpan(childSpan, client); + }); + }); + + expect(serializedChildSpan?.name).toBe('child'); + expect(serializedChildSpan?.is_segment).toBe(false); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: serializedChildSpan?.span_id })); + expect(processSegmentSpanFn).not.toHaveBeenCalled(); + }); + }); + + describe('beforeSendSpan', () => { + it('applies beforeSendSpan if it is a span streaming compatible callback', () => { + const beforeSendSpan = withStreamedSpan(vi.fn(span => span)); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + 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); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + }); + + 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 client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); + +describe('safeSetSpanJSONAttributes', () => { + it('sets attributes that do not exist', () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { c: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("doesn't set attributes that already exist", () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2 }); + }); + + it.each([null, undefined])("doesn't overwrite attributes previously set to %s", val => { + const spanJSON = { attributes: { a: val, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: val, b: 2 }); + }); + + it("doesn't overwrite falsy attribute values (%s)", () => { + const spanJSON = { attributes: { a: false, b: '', c: 0 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1, b: 'test', c: 1 }); + + expect(spanJSON.attributes).toEqual({ a: false, b: '', c: 0 }); + }); + + it('handles an undefined attributes property', () => { + const spanJSON: Partial = {}; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: 1 }); + }); + + it("doesn't apply undefined or null values to attributes", () => { + const spanJSON = { attributes: {} }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: undefined, b: null }); + + expect(spanJSON.attributes).toEqual({}); + }); +}); From f5f461f3afd20d1141f5bdd53ec87d4891c8b3ca Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 9 Feb 2026 11:05:00 +0100 Subject: [PATCH 06/12] feat(core): Add `SpanBuffer` implementation (#19204) This PR adds a simple span buffer implementation to be used for buffering streamed spans. Behaviour: - buckets incoming spans by `traceId`, as we must not mix up spans of different traces in one envelope - flushes the entire buffer every 5s by default - flushes the specific trace bucket if the max span limit (1000) is reached. Relay accepts at max. 1000 spans per envelope - computes the DSC when flushing the first span of a trace. This is the latest time we can do it as once we flushed we have to freeze the DSC for Dynamic Sampling consistency - debounces the flush interval whenever we flush - flushes the entire buffer if `Sentry.flush()` is called - shuts down the interval-based flushing when `Sentry.close()` is called - [implicit] Client report generation for dropped envelopes is handled in the transport Methods: - `add` accepts a new span to be enqueued into the buffer - `drain` flushes the entire buffer - `flush(traceId)` flushes a specific traceId bucket. This can be used by e.g. the browser span streaming implementation to flush out the trace of a segment span directly once it ends. Options: - `maxSpanLimit` - allows to configure a 0 < maxSpanLimit < 1000 custom span limit. Useful for testing but we could also expose this to users if we see a need - `flushInterval`- allows to configure a >0 flush interval Limitations/edge cases: - No maximum limit of concurrently buffered traces. I'd tend to accept this for now and see where this leads us in terms of memory pressure but at the end of the day, the interval based flushing, in combination with our promise buffer _should_ avoid an ever-growing map of trace buckets. Happy to change this if reviewers have strong opinions or I'm missing something important! - There's no priority based scheduling relative to other telemetry items. Just like with our other log and metric buffers. - since `Map` is [insertion order preserving](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#description), we apply a FIFO strategy when`drain`ing the trace buckets. This is in line with our [develop spec](https://develop.sentry.dev/sdk/telemetry/telemetry-processor/backend-telemetry-processor/#:~:text=The%20span%20buffer,in%20the%20buffer.) for the telemetry processor but might lead to cases where new traces are dropped by the promise buffer if a lof of concurrently running traces are flushed. I think that's a fine trade off. ref #19119 --- packages/core/src/index.ts | 3 + .../src/tracing/dynamicSamplingContext.ts | 3 +- .../core/src/tracing/spans/captureSpan.ts | 2 +- packages/core/src/tracing/spans/spanBuffer.ts | 173 ++++++++++++ .../test/lib/tracing/spans/spanBuffer.test.ts | 262 ++++++++++++++++++ 5 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/tracing/spans/spanBuffer.ts create mode 100644 packages/core/test/lib/tracing/spans/spanBuffer.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f6f2e864b25..fb4ae046673f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -180,6 +180,9 @@ export type { GoogleGenAIOptions, GoogleGenAIIstrumentedMethod, } from './tracing/google-genai/types'; + +export { SpanBuffer } from './tracing/spans/spanBuffer'; + export type { FeatureFlag } from './utils/featureFlags'; export { diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 47d5657a7d87..7cf79e53d07b 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -119,7 +119,8 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly>; + + private _flushIntervalId: ReturnType | null; + private _client: Client; + private _maxSpanLimit: number; + private _flushInterval: number; + + public constructor(client: Client, options?: SpanBufferOptions) { + this._traceMap = new Map(); + this._client = client; + + const { maxSpanLimit, flushInterval } = options ?? {}; + + this._maxSpanLimit = + maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE + ? maxSpanLimit + : MAX_SPANS_PER_ENVELOPE; + this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000; + + this._flushIntervalId = null; + this._debounceFlushInterval(); + + this._client.on('flush', () => { + this.drain(); + }); + + this._client.on('close', () => { + // No need to drain the buffer here as `Client.close()` internally already calls `Client.flush()` + // which already invokes the `flush` hook and thus drains the buffer. + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._traceMap.clear(); + }); + } + + /** + * Add a span to the buffer. + */ + public add(spanJSON: SerializedStreamedSpanWithSegmentSpan): void { + const traceId = spanJSON.trace_id; + let traceBucket = this._traceMap.get(traceId); + if (traceBucket) { + traceBucket.add(spanJSON); + } else { + traceBucket = new Set([spanJSON]); + this._traceMap.set(traceId, traceBucket); + } + + if (traceBucket.size >= this._maxSpanLimit) { + this.flush(traceId); + this._debounceFlushInterval(); + } + } + + /** + * Drain and flush all buffered traces. + */ + public drain(): void { + if (!this._traceMap.size) { + return; + } + + DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._traceMap.size} traces`); + + this._traceMap.forEach((_, traceId) => { + this.flush(traceId); + }); + this._debounceFlushInterval(); + } + + /** + * Flush spans of a specific trace. + * In contrast to {@link SpanBuffer.flush}, this method does not flush all traces, but only the one with the given traceId. + */ + public flush(traceId: string): void { + const traceBucket = this._traceMap.get(traceId); + if (!traceBucket) { + return; + } + + if (!traceBucket.size) { + // we should never get here, given we always add a span when we create a new bucket + // and delete the bucket once we flush out the trace + this._traceMap.delete(traceId); + return; + } + + const spans = Array.from(traceBucket); + + const segmentSpan = spans[0]?._segmentSpan; + if (!segmentSpan) { + DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); + this._traceMap.delete(traceId); + return; + } + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + const cleanedSpans: SerializedStreamedSpan[] = spans.map(spanJSON => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _segmentSpan, ...cleanSpanJSON } = spanJSON; + return cleanSpanJSON; + }); + + const envelope = createStreamedSpanEnvelope(cleanedSpans, dsc, this._client); + + DEBUG_BUILD && debug.log(`Sending span envelope for trace ${traceId} with ${cleanedSpans.length} spans`); + + this._client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason); + }); + + this._traceMap.delete(traceId); + } + + private _debounceFlushInterval(): void { + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._flushIntervalId = safeUnref( + setInterval(() => { + this.drain(); + }, this._flushInterval), + ); + } +} diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts new file mode 100644 index 000000000000..1b654cd400e6 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client, StreamedSpanEnvelope } from '../../../../src'; +import { SentrySpan, setCurrentClient, SpanBuffer } from '../../../../src'; +import type { SerializedStreamedSpanWithSegmentSpan } from '../../../../src/tracing/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('SpanBuffer', () => { + let client: TestClient; + let sendEnvelopeSpy: ReturnType; + + let sentEnvelopes: Array = []; + + beforeEach(() => { + vi.useFakeTimers(); + sentEnvelopes = []; + sendEnvelopeSpy = vi.fn().mockImplementation(e => { + sentEnvelopes.push(e); + return Promise.resolve(); + }); + + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1.0, + }), + ); + client.sendEnvelope = sendEnvelopeSpy; + client.init(); + setCurrentClient(client as Client); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('flushes all traces on drain()', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace123' }); + const segmentSpan2 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace456' }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace456', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + expect(sentEnvelopes).toHaveLength(2); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace123'); + expect(sentEnvelopes[1]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace456'); + }); + + it('drains on interval', () => { + const buffer = new SpanBuffer(client, { flushInterval: 1000 }); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true }); + const span1 = { + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }; + + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + const span2 = { + trace_id: 'trace123', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }; + + buffer.add(span1 as SerializedStreamedSpanWithSegmentSpan); + buffer.add(span2 as SerializedStreamedSpanWithSegmentSpan); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // since the buffer is now empty, it should not send anything anymore + vi.advanceTimersByTime(1000); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('flushes when maxSpanLimit is reached', () => { + const buffer = new SpanBuffer(client, { maxSpanLimit: 2 }); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span3', + name: 'test span 3', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + // we added another span after flushing but neither limit nor time interval should have been reached + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // draining will flush out the remaining span + buffer.drain(); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes on client flush event', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + client.emit('flush'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('groups spans by traceId', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + // Should send 2 envelopes, one for each trace + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes a specific trace on flush(traceId)', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace1'); + }); + + it('handles flushing a non-existing trace', () => { + const buffer = new SpanBuffer(client); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); +}); From a1c8a388d220cdf176d5a972c9679de059a2d604 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 19 Feb 2026 17:38:23 +0100 Subject: [PATCH 07/12] feat(browser): Add `spanStreamingIntegration` (#19218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the final big building block for span streaming functionality in the browser SDK: `spanStreamingIntegation`. This integration: - enables `traceLifecycle: 'stream'` if not already set by users. This allows us to avoid the double-opt-in problem we usually have in browser SDKs because we want to keep integration tree-shakeable but also support the runtime-agnostic `traceLifecycle` option. - to do this properly, I decided to introduce a new integration hook: `beforeSetup`. This is allows us to safely modify client options before other integrations read it. We'll need this because `browserTracingIntegration` needs to check for span streaming later on. Let me know what you think! - validates that `beforeSendSpan` is compatible with span streaming. If not, it falls back to static tracing (transactions). - listens to a new `afterSpanEnd` hook. Once called, it will capture the span and hand it off to the span buffer. - listens to a new `afterSegmentSpanEnd` hook. Once called it will flush the trace from the buffer to ensure we flush out the trace as soon as possible. In browser, it's more likely that users refresh or close the tab/window before our buffer's internal flush interval triggers. We don't _have_ to do this but I figured it would be a good trigger point. While "final building block" sounds nice, there's still a lot of stuff to take care of in the browser. But with this in place we can also start integration-testing the browser SDKs. ref #17836 --------- Co-authored-by: Jan Peer Stöcklmair --- .size-limit.js | 6 +- packages/browser/src/index.ts | 1 + .../browser/src/integrations/spanstreaming.ts | 55 ++++++ .../test/integrations/spanstreaming.test.ts | 167 ++++++++++++++++++ packages/core/src/client.ts | 32 +++- packages/core/src/envelope.ts | 2 +- packages/core/src/index.ts | 4 +- packages/core/src/integration.ts | 6 + packages/core/src/tracing/sentrySpan.ts | 13 ++ .../spans}/beforeSendSpan.ts | 6 +- .../core/src/tracing/spans/captureSpan.ts | 4 +- .../tracing/spans/hasSpanStreamingEnabled.ts | 8 + packages/core/src/tracing/trace.ts | 1 + packages/core/src/types-hoist/integration.ts | 9 + .../spans}/beforeSendSpan.test.ts | 4 +- 15 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 packages/browser/src/integrations/spanstreaming.ts create mode 100644 packages/browser/test/integrations/spanstreaming.test.ts rename packages/core/src/{utils => tracing/spans}/beforeSendSpan.ts (89%) create mode 100644 packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts rename packages/core/test/lib/{utils => tracing/spans}/beforeSendSpan.test.ts (85%) diff --git a/.size-limit.js b/.size-limit.js index 35681660e4f2..bed4d677b736 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -15,7 +15,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '24.5 KB', + limit: '25 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -255,7 +255,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '132 KB', + limit: '133 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', @@ -269,7 +269,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '246 KB', + limit: '247 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 70a6595d07d9..071f38f72ca9 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -41,6 +41,7 @@ export { } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; +export { spanStreamingIntegration } from './integrations/spanstreaming'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts new file mode 100644 index 000000000000..e8e3f8bcd542 --- /dev/null +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -0,0 +1,55 @@ +import type { IntegrationFn } from '@sentry/core'; +import { + captureSpan, + debug, + defineIntegration, + hasSpanStreamingEnabled, + isStreamedBeforeSendSpanCallback, + SpanBuffer, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +export const spanStreamingIntegration = defineIntegration(() => { + return { + name: 'SpanStreaming', + + beforeSetup(client) { + // If users only set spanStreamingIntegration, without traceLifecycle, we set it to "stream" for them. + // This avoids the classic double-opt-in problem we'd otherwise have in the browser SDK. + const clientOptions = client.getOptions(); + if (!clientOptions.traceLifecycle) { + DEBUG_BUILD && debug.warn('[SpanStreaming] set `traceLifecycle` to "stream"'); + clientOptions.traceLifecycle = 'stream'; + } + }, + + setup(client) { + const initialMessage = 'SpanStreaming integration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + + if (!hasSpanStreamingEnabled(client)) { + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); + return; + } + + const beforeSendSpan = client.getOptions().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)) { + client.getOptions().traceLifecycle = 'static'; + DEBUG_BUILD && + debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + return; + } + + const buffer = new SpanBuffer(client); + + client.on('afterSpanEnd', span => buffer.add(captureSpan(span, client))); + + // In addition to capturing the span, we also flush the trace when the segment + // span ends to ensure things are sent timely. We never know when the browser + // is closed, users navigate away, etc. + client.on('afterSegmentSpanEnd', segmentSpan => buffer.flush(segmentSpan.spanContext().traceId)); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts new file mode 100644 index 000000000000..6993e494f9ce --- /dev/null +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -0,0 +1,167 @@ +import * as SentryCore from '@sentry/core'; +import { debug } from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; +import { BrowserClient, spanStreamingIntegration } from '../../src'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; + +// Mock SpanBuffer as a class that can be instantiated +const mockSpanBufferInstance = vi.hoisted(() => ({ + flush: vi.fn(), + add: vi.fn(), + drain: vi.fn(), +})); + +const MockSpanBuffer = vi.hoisted(() => { + return vi.fn(() => mockSpanBufferInstance); +}); + +vi.mock('@sentry/core', async () => { + const original = await vi.importActual('@sentry/core'); + return { + ...original, + SpanBuffer: MockSpanBuffer, + }; +}); + +describe('spanStreamingIntegration', () => { + it('has the correct hooks', () => { + const integration = spanStreamingIntegration(); + expect(integration.name).toBe('SpanStreaming'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(integration.beforeSetup).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(integration.setup).toBeDefined(); + }); + + it('sets traceLifecycle to "stream" if not set', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('logs a warning if traceLifecycle is not set to "stream"', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'static', + }); + + 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(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + beforeSendSpan: (span: Span) => span, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('does nothing if traceLifecycle set to "stream"', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('enqueues a span into the buffer when the span ends', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test' }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({ + _segmentSpan: span, + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'test', + start_timestamp: expect.any(Number), + status: 'ok', + attributes: { + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: span.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'test', + }, + }, + }); + }); + + it('flushes the trace when the segment span ends', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test' }); + client.emit('afterSegmentSpanEnd', span); + + expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index ccb52d0e83ff..6c3ca949f38e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -11,6 +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 { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb'; import type { CheckIn, MonitorConfig } from './types-hoist/checkin'; @@ -34,7 +35,6 @@ import type { SeverityLevel } from './types-hoist/severity'; import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; -import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; import { createClientReportEnvelope } from './utils/clientreport'; import { debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; @@ -504,6 +504,10 @@ export abstract class Client { public addIntegration(integration: Integration): void { const isAlreadyInstalled = this._integrations[integration.name]; + if (!isAlreadyInstalled && integration.beforeSetup) { + integration.beforeSetup(this); + } + // This hook takes care of only installing if not already installed setupIntegration(this, integration, this._integrations); // Here we need to check manually to make sure to not run this multiple times @@ -614,6 +618,18 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for after a span is ended and the `spanEnd` hook has run. + * NOTE: The span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void; + + /** + * Register a callback for after a segment span is ended and the `segmentSpanEnd` hook has run. + * NOTE: The segment span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSegmentSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void; + /** * Register a callback for when a span JSON is processed, to add some data to the span JSON. */ @@ -897,12 +913,22 @@ export abstract class Client { public emit(hook: 'spanEnd', span: Span): void; /** - * Register a callback for when a span JSON is processed, to add some data to the span JSON. + * Fire a hook event after a span ends and the `spanEnd` hook has run. + */ + public emit(hook: 'afterSpanEnd', immutableSpan: Readonly): void; + + /** + * Fire a hook event after a segment span ends and the `spanEnd` hook has run. + */ + public emit(hook: 'afterSegmentSpanEnd', immutableSegmentSpan: Readonly): void; + + /** + * Fire a hook event when a span JSON is processed, to add some data to the span JSON. */ public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void; /** - * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON. + * Fire a hook event for when a segment span JSON is processed, to add some data to the segment span JSON. */ public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void; diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index c7a46359260f..dd91d077f45c 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -1,6 +1,7 @@ import type { Client } from './client'; import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext'; import type { SentrySpan } from './tracing/sentrySpan'; +import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import type { LegacyCSPReport } from './types-hoist/csp'; import type { DsnComponents } from './types-hoist/dsn'; import type { @@ -18,7 +19,6 @@ import type { Event } from './types-hoist/event'; import type { SdkInfo } from './types-hoist/sdkinfo'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; import type { Session, SessionAggregates } from './types-hoist/session'; -import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; import { dsnToString } from './utils/dsn'; import { createEnvelope, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb4ae046673f..d5d926f51769 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,7 +68,8 @@ export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; -export { withStreamedSpan } from './utils/beforeSendSpan'; +export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; +export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; @@ -182,6 +183,7 @@ export type { } from './tracing/google-genai/types'; export { SpanBuffer } from './tracing/spans/spanBuffer'; +export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; export type { FeatureFlag } from './utils/featureFlags'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 892228476824..b8e7240cf748 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -76,6 +76,12 @@ export function getIntegrationsToSetup( export function setupIntegrations(client: Client, integrations: Integration[]): IntegrationIndex { const integrationIndex: IntegrationIndex = {}; + integrations.forEach((integration: Integration | undefined) => { + if (integration?.beforeSetup) { + integration.beforeSetup(client); + } + }); + integrations.forEach((integration: Integration | undefined) => { // guard against empty provided integrations if (integration) { diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8bdae7129dba..d9ab115b94cd 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -45,6 +45,7 @@ import { timestampInSeconds } from '../utils/time'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; +import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { getCapturedScopesOnSpan } from './utils'; const MAX_SPAN_COUNT = 1000; @@ -315,6 +316,14 @@ export class SentrySpan implements Span { const client = getClient(); if (client) { client.emit('spanEnd', this); + // Guarding sending standalone v1 spans as v2 streamed spans for now. + // Otherwise they'd be sent once as v1 spans and again as streamed spans. + // We'll migrate CLS and LCP spans to streamed spans in a later PR and + // INP spans in the next major of the SDK. At that point, we can fully remove + // standalone v1 spans <3 + if (!this._isStandaloneSpan) { + client.emit('afterSpanEnd', this); + } } // A segment span is basically the root span of a local span tree. @@ -338,6 +347,10 @@ export class SentrySpan implements Span { } } return; + } else if (client && hasSpanStreamingEnabled(client)) { + // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans + client.emit('afterSegmentSpanEnd', this); + return; } const transactionEvent = this._convertSpanToTransaction(); diff --git a/packages/core/src/utils/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts similarity index 89% rename from packages/core/src/utils/beforeSendSpan.ts rename to packages/core/src/tracing/spans/beforeSendSpan.ts index 68c4576d179d..84ec6a8a8b52 100644 --- a/packages/core/src/utils/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -1,6 +1,6 @@ -import type { BeforeSendStramedSpanCallback, ClientOptions } from '../types-hoist/options'; -import type { StreamedSpanJSON } from '../types-hoist/span'; -import { addNonEnumerableProperty } from './object'; +import type { BeforeSendStramedSpanCallback, ClientOptions } from '../../types-hoist/options'; +import type { StreamedSpanJSON } from '../../types-hoist/span'; +import { addNonEnumerableProperty } from '../../utils/object'; /** * A wrapper to use the new span format in your `beforeSendSpan` callback. diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index a6d0df8725cf..979c7b460af1 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -15,7 +15,6 @@ import { SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; -import { isStreamedBeforeSendSpanCallback } from '../../utils/beforeSendSpan'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, @@ -24,6 +23,7 @@ import { streamedSpanJsonToSerializedSpan, } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; +import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { _segmentSpan: Span; @@ -51,7 +51,7 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); - if (span === segmentSpan) { + if (spanJSON.is_segment) { applyScopeToSegmentSpan(spanJSON, finalScopeData); // Allow hook subscribers to mutate the segment span JSON client.emit('processSegmentSpan', spanJSON); diff --git a/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts new file mode 100644 index 000000000000..7d5fa2861c21 --- /dev/null +++ b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts @@ -0,0 +1,8 @@ +import type { Client } from '../../client'; + +/** + * Determines if span streaming is enabled for the given client + */ +export function hasSpanStreamingEnabled(client: Client): boolean { + return client.getOptions().traceLifecycle === 'stream'; +} diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 28a5bccd4147..59b00bb018c1 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -492,6 +492,7 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp // If it has an endTimestamp, it's already ended if (spanArguments.endTimestamp) { client.emit('spanEnd', childSpan); + client.emit('afterSpanEnd', childSpan); } } diff --git a/packages/core/src/types-hoist/integration.ts b/packages/core/src/types-hoist/integration.ts index 120cb1acc884..fc80cf3f524a 100644 --- a/packages/core/src/types-hoist/integration.ts +++ b/packages/core/src/types-hoist/integration.ts @@ -14,6 +14,15 @@ export interface Integration { */ setupOnce?(): void; + /** + * Called before the `setup` hook of any integration is called. + * This is useful if an integration needs to e.g. modify client options prior to other integrations + * reading client options. + * + * @param client + */ + beforeSetup?(client: Client): void; + /** * Set up an integration for the given client. * Receives the client as argument. diff --git a/packages/core/test/lib/utils/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts similarity index 85% rename from packages/core/test/lib/utils/beforeSendSpan.test.ts rename to packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts index 5e5bdc566889..79fd838a1b27 100644 --- a/packages/core/test/lib/utils/beforeSendSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { withStreamedSpan } from '../../../src'; -import { isStreamedBeforeSendSpanCallback } from '../../../src/utils/beforeSendSpan'; +import { withStreamedSpan } from '../../../../src'; +import { isStreamedBeforeSendSpanCallback } from '../../../../src/tracing/spans/beforeSendSpan'; describe('beforeSendSpan for span streaming', () => { describe('withStreamedSpan', () => { From 7897a989769e41d91be9769f4782c12ace24b6e9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 2 Mar 2026 14:40:47 +0100 Subject: [PATCH 08/12] feat(core): Add weight-based flushing to span buffer (#19579) Adds weight-based flushing and span size estimation to the span buffer. Behaviour: - tracks weight independently per trace - weight estimation follows the same strategy we use for logs and metrics. I optimized the calculation, adding fixed sizes for as many fields as possible. Only span name, attributes and links are computed dynamically, with the same assumptions and considerations as in logs and metrics. - My tests show that the size estimation roughly compares to factor 0.8 to 1.2 to the real sizes, depending on data on spans (no, few, many, primitive, array attributes and links, etc.) - For now, the limit is set to 5MB which is half of the 10MB Relay accepts for span envelopes. --- packages/core/src/attributes.ts | 42 +++++ .../core/src/tracing/spans/estimateSize.ts | 37 ++++ packages/core/src/tracing/spans/spanBuffer.ts | 33 +++- .../lib/tracing/spans/estimateSize.test.ts | 177 ++++++++++++++++++ .../test/lib/tracing/spans/spanBuffer.test.ts | 97 ++++++++++ 5 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tracing/spans/estimateSize.ts create mode 100644 packages/core/test/lib/tracing/spans/estimateSize.test.ts diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index d3255d76b0e9..1f4a6638f577 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,4 +1,6 @@ import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; +import type { Primitive } from './types-hoist/misc'; +import { isPrimitive } from './utils/is'; export type RawAttributes = T & ValidatedAttributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -127,6 +129,46 @@ export function serializeAttributes( return serializedAttributes; } +/** + * Estimates the serialized byte size of {@link Attributes}, + * with a couple of heuristics for performance. + */ +export function estimateTypedAttributesSizeInBytes(attributes: Attributes | undefined): number { + if (!attributes) { + return 0; + } + let weight = 0; + for (const [key, attr] of Object.entries(attributes)) { + weight += key.length * 2; + weight += attr.type.length * 2; + weight += (attr.unit?.length ?? 0) * 2; + const val = attr.value; + + if (Array.isArray(val)) { + // Assumption: Individual array items have the same type and roughly the same size + // probably not always true but allows us to cut down on runtime + weight += estimatePrimitiveSizeInBytes(val[0]) * val.length; + } else if (isPrimitive(val)) { + weight += estimatePrimitiveSizeInBytes(val); + } else { + // default fallback for anything else (objects) + weight += 100; + } + } + return weight; +} + +function estimatePrimitiveSizeInBytes(value: Primitive): number { + if (typeof value === 'string') { + return value.length * 2; + } else if (typeof value === 'boolean') { + return 4; + } else if (typeof value === 'number') { + return 8; + } + return 0; +} + /** * NOTE: We intentionally do not return anything for non-primitive values: * - array support will come in the future but if we stringify arrays now, diff --git a/packages/core/src/tracing/spans/estimateSize.ts b/packages/core/src/tracing/spans/estimateSize.ts new file mode 100644 index 000000000000..7d5781862d62 --- /dev/null +++ b/packages/core/src/tracing/spans/estimateSize.ts @@ -0,0 +1,37 @@ +import { estimateTypedAttributesSizeInBytes } from '../../attributes'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; + +/** + * Estimates the serialized byte size of a {@link SerializedStreamedSpan}. + * + * Uses 2 bytes per character as a UTF-16 approximation, and 8 bytes per number. + * The estimate is intentionally conservative and may be slightly lower than the + * actual byte size on the wire. + * We compensate for this by setting the span buffers internal limit well below the limit + * of how large an actual span v2 envelope may be. + */ +export function estimateSerializedSpanSizeInBytes(span: SerializedStreamedSpan): number { + /* + * Fixed-size fields are pre-computed as a constant for performance: + * - two timestamps (8 bytes each = 16) + * - is_segment boolean (5 bytes, assumed false for most spans) + * - trace_id – always 32 hex chars (64 bytes) + * - span_id – always 16 hex chars (32 bytes) + * - parent_span_id – 16 hex chars, assumed present for most spans (32 bytes) + * - status "ok" – most common value (8 bytes) + * = 156 bytes total base + */ + let weight = 156; + weight += span.name.length * 2; + weight += estimateTypedAttributesSizeInBytes(span.attributes); + if (span.links && span.links.length > 0) { + // Assumption: Links are roughly equal in number of attributes + // probably not always true but allows us to cut down on runtime + const firstLink = span.links[0]; + const attributes = firstLink?.attributes; + // Fixed size 100 due to span_id, trace_id and sampled flag (see above) + const linkWeight = 100 + (attributes ? estimateTypedAttributesSizeInBytes(attributes) : 0); + weight += linkWeight * span.links.length; + } + return weight; +} diff --git a/packages/core/src/tracing/spans/spanBuffer.ts b/packages/core/src/tracing/spans/spanBuffer.ts index 761ba076e4d2..d6451acb5e44 100644 --- a/packages/core/src/tracing/spans/spanBuffer.ts +++ b/packages/core/src/tracing/spans/spanBuffer.ts @@ -6,6 +6,7 @@ import { safeUnref } from '../../utils/timer'; import { getDynamicSamplingContextFromSpan } from '../dynamicSamplingContext'; import type { SerializedStreamedSpanWithSegmentSpan } from './captureSpan'; import { createStreamedSpanEnvelope } from './envelope'; +import { estimateSerializedSpanSizeInBytes } from './estimateSize'; /** * We must not send more than 1000 spans in one envelope. @@ -13,6 +14,8 @@ import { createStreamedSpanEnvelope } from './envelope'; */ const MAX_SPANS_PER_ENVELOPE = 1000; +const MAX_TRACE_WEIGHT_IN_BYTES = 5_000_000; + export interface SpanBufferOptions { /** * Max spans per trace before auto-flush @@ -29,6 +32,14 @@ export interface SpanBufferOptions { * @default 5_000 */ flushInterval?: number; + + /** + * Max accumulated byte weight of spans per trace before auto-flush. + * Size is estimated, not exact. Uses 2 bytes per character for strings (UTF-16). + * + * @default 5_000_000 (5 MB) + */ + maxTraceWeightInBytes?: number; } /** @@ -45,23 +56,28 @@ export interface SpanBufferOptions { export class SpanBuffer { /* Bucket spans by their trace id */ private _traceMap: Map>; + private _traceWeightMap: Map; private _flushIntervalId: ReturnType | null; private _client: Client; private _maxSpanLimit: number; private _flushInterval: number; + private _maxTraceWeight: number; public constructor(client: Client, options?: SpanBufferOptions) { this._traceMap = new Map(); + this._traceWeightMap = new Map(); this._client = client; - const { maxSpanLimit, flushInterval } = options ?? {}; + const { maxSpanLimit, flushInterval, maxTraceWeightInBytes } = options ?? {}; this._maxSpanLimit = maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE ? maxSpanLimit : MAX_SPANS_PER_ENVELOPE; this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000; + this._maxTraceWeight = + maxTraceWeightInBytes && maxTraceWeightInBytes > 0 ? maxTraceWeightInBytes : MAX_TRACE_WEIGHT_IN_BYTES; this._flushIntervalId = null; this._debounceFlushInterval(); @@ -77,6 +93,7 @@ export class SpanBuffer { clearInterval(this._flushIntervalId); } this._traceMap.clear(); + this._traceWeightMap.clear(); }); } @@ -93,7 +110,10 @@ export class SpanBuffer { this._traceMap.set(traceId, traceBucket); } - if (traceBucket.size >= this._maxSpanLimit) { + const newWeight = (this._traceWeightMap.get(traceId) ?? 0) + estimateSerializedSpanSizeInBytes(spanJSON); + this._traceWeightMap.set(traceId, newWeight); + + if (traceBucket.size >= this._maxSpanLimit || newWeight >= this._maxTraceWeight) { this.flush(traceId); this._debounceFlushInterval(); } @@ -128,7 +148,7 @@ export class SpanBuffer { if (!traceBucket.size) { // we should never get here, given we always add a span when we create a new bucket // and delete the bucket once we flush out the trace - this._traceMap.delete(traceId); + this._removeTrace(traceId); return; } @@ -137,7 +157,7 @@ export class SpanBuffer { const segmentSpan = spans[0]?._segmentSpan; if (!segmentSpan) { DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); - this._traceMap.delete(traceId); + this._removeTrace(traceId); return; } @@ -157,7 +177,12 @@ export class SpanBuffer { DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason); }); + this._removeTrace(traceId); + } + + private _removeTrace(traceId: string): void { this._traceMap.delete(traceId); + this._traceWeightMap.delete(traceId); } private _debounceFlushInterval(): void { diff --git a/packages/core/test/lib/tracing/spans/estimateSize.test.ts b/packages/core/test/lib/tracing/spans/estimateSize.test.ts new file mode 100644 index 000000000000..35d569691dea --- /dev/null +++ b/packages/core/test/lib/tracing/spans/estimateSize.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { estimateSerializedSpanSizeInBytes } from '../../../../src/tracing/spans/estimateSize'; +import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; + +// Produces a realistic trace_id (32 hex chars) and span_id (16 hex chars) +const TRACE_ID = 'a1b2c3d4e5f607189a0b1c2d3e4f5060'; +const SPAN_ID = 'a1b2c3d4e5f60718'; + +describe('estimateSerializedSpanSizeInBytes', () => { + it('estimates a minimal span (no attributes, no links, no parent) within a reasonable range of JSON.stringify', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'GET /api/users', + start_timestamp: 1740000000.123, + end_timestamp: 1740000001.456, + status: 'ok', + is_segment: true, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBe(184); + expect(actual).toBe(196); + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with a parent_span_id within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + parent_span_id: 'b2c3d4e5f6071890', + name: 'db.query', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.05, + status: 'ok', + is_segment: false, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBe(172); + expect(actual).toBe(222); + + expect(estimate).toBeLessThanOrEqual(actual * 1.1); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.7); + }); + + it('estimates a span with string attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'GET /api/users', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.1, + status: 'ok', + is_segment: false, + attributes: { + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: 'https://example.com/api/users?page=1&limit=100' }, + 'http.status_code': { type: 'integer', value: 200 }, + 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = $1' }, + 'sentry.origin': { type: 'string', value: 'auto.http.fetch' }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with numeric attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'process.task', + start_timestamp: 1740000000.0, + end_timestamp: 1740000005.0, + status: 'ok', + is_segment: false, + attributes: { + 'items.count': { type: 'integer', value: 42 }, + 'duration.ms': { type: 'double', value: 5000.5 }, + 'retry.count': { type: 'integer', value: 3 }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with boolean attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'cache.get', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.002, + status: 'ok', + is_segment: false, + attributes: { + 'cache.hit': { type: 'boolean', value: true }, + 'cache.miss': { type: 'boolean', value: false }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with array attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'batch.process', + start_timestamp: 1740000000.0, + end_timestamp: 1740000002.0, + status: 'ok', + is_segment: false, + attributes: { + 'item.ids': { type: 'string[]', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] }, + scores: { type: 'double[]', value: [1.1, 2.2, 3.3, 4.4] }, + flags: { type: 'boolean[]', value: [true, false, true] }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with links within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'linked.operation', + start_timestamp: 1740000000.0, + end_timestamp: 1740000001.0, + status: 'ok', + is_segment: true, + links: [ + { + trace_id: 'b2c3d4e5f607189a0b1c2d3e4f506070', + span_id: 'c3d4e5f607189a0b', + sampled: true, + attributes: { + 'sentry.link.type': { type: 'string', value: 'previous_trace' }, + }, + }, + { + trace_id: 'c3d4e5f607189a0b1c2d3e4f50607080', + span_id: 'd4e5f607189a0b1c', + }, + ], + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts index 1b654cd400e6..44a6f6f954db 100644 --- a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts +++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts @@ -259,4 +259,101 @@ describe('SpanBuffer', () => { expect(sendEnvelopeSpy).not.toHaveBeenCalled(); }); + + describe('weight-based flushing', () => { + function makeSpan( + traceId: string, + spanId: string, + segmentSpan: InstanceType, + overrides: Partial = {}, + ): SerializedStreamedSpanWithSegmentSpan { + return { + trace_id: traceId, + span_id: spanId, + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + ...overrides, + }; + } + + it('flushes a trace when its weight limit is exceeded', () => { + // Use a very small weight threshold so a single span with attributes tips it over + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // First span: small, under threshold + buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a' })); + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + // Second span: has a large name that pushes it over 200 bytes + buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'a'.repeat(80) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('does not flush when weight stays below the threshold', () => { + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 10_000 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add(makeSpan('trace1', 'span1', segmentSpan)); + buffer.add(makeSpan('trace1', 'span2', segmentSpan)); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + + it('resets weight tracking after a weight-triggered flush so new spans accumulate fresh weight', () => { + // Base estimate per span is 152 bytes. With threshold 400: + // - big span ('a' * 200): 152 + 200*2 = 552 bytes → exceeds 400, triggers flush + // - small span (name 'b'): 152 + 1*2 = 154 bytes + // - two small spans combined: 308 bytes < 400 → no second flush + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 400 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a'.repeat(200) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'b' })); + buffer.add(makeSpan('trace1', 'span3', segmentSpan, { name: 'c' })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('tracks weight independently per trace', () => { + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 }); + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + // trace1 gets a heavy span that exceeds the limit + buffer.add(makeSpan('trace1', 'span1', segmentSpan1, { name: 'a'.repeat(80) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect((sentEnvelopes[0]?.[1]?.[0]?.[1] as { items: Array<{ trace_id: string }> })?.items[0]?.trace_id).toBe( + 'trace1', + ); + + // trace2 only has a small span and should not be flushed + buffer.add(makeSpan('trace2', 'span2', segmentSpan2, { name: 'b' })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('estimates spans with attributes as heavier than bare spans', () => { + // Use a threshold that a bare span cannot reach but an attributed span can + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 300 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // A span with many string attributes should tip it over + buffer.add( + makeSpan('trace1', 'span1', segmentSpan, { + attributes: { + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: 'https://example.com/api/v1/users?page=1&limit=100' }, + 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = 1' }, + }, + }), + ); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + }); }); From d29b2d33ac815eadc89fc22d938317130a9c593f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Mar 2026 13:33:13 +0100 Subject: [PATCH 09/12] test(browser): Add span streaming integration tests (#19581) This PR adds browser integration to test testing span streaming: - Added test helpers: - `waitForStreamedSpan`: Returns a promise of a single matching span - `waitForStreamedSpans`: Returns a promise of all spans in an array whenever the callback returns true - `waitForStreamedSpanEnvelope`: Returns an entire streamed span (v2) envelope (including headers) - `observeStreamedSpan`: Can be used to observe sent span envelopes without blocking the test if no envelopes are sent (good for testing that spans are _not_ sent) - `getSpanOp`: Small helper to easily get the op of a span which we almost always need for the `waitFor*` function callbacks Added 50+ tests, mostly converted from transaction integration tests around spans from `browserTracingIntegration`: - tests asserting the entire span v2 envelope payloads of manually started, pageload and navigation span trees - tests for trace linking and trace lifetime - tests for spans coming from browserTracingIntegration (fetch, xhr, long animation frame, long tasks) Also, this PR fixes two bugs discovered through tests: - negatively sampled spans were still sent (because non-recording spans go through the same span life cycle) - cancelled spans received status `error` instead of `ok`. We want them to have status `ok` but an attribute detailing the cancellation reason. Lastly, I discovered a problem with timing data on fetch and XHR spans. Will try to fix as a follow-up. Tracked in #19613 ref #17836 --- .size-limit.js | 2 +- .../public-api/startSpan/streamed/init.js | 10 + .../public-api/startSpan/streamed/subject.js | 13 + .../public-api/startSpan/streamed/test.ts | 217 ++++++++++++ .../backgroundtab-pageload-streamed/init.js | 10 + .../subject.js | 8 + .../template.html | 9 + .../backgroundtab-pageload-streamed/test.ts | 18 + .../http-timings-streamed/init.js | 19 ++ .../http-timings-streamed/subject.js | 3 + .../http-timings-streamed/test.ts | 71 ++++ .../interactions-streamed/init.js | 18 + .../interactions-streamed/subject.js | 16 + .../interactions-streamed/template.html | 14 + .../interactions-streamed/test.ts | 134 ++++++++ .../consistent-sampling/default/init.js | 22 ++ .../consistent-sampling/default/subject.js | 17 + .../consistent-sampling/default/template.html | 8 + .../consistent-sampling/default/test.ts | 153 +++++++++ .../consistent-sampling/meta-negative/init.js | 19 ++ .../meta-negative/subject.js | 17 + .../meta-negative/template.html | 13 + .../consistent-sampling/meta-negative/test.ts | 97 ++++++ .../meta-precedence/init.js | 20 ++ .../meta-precedence/page-1.html | 15 + .../meta-precedence/page-2.html | 10 + .../meta-precedence/subject.js | 17 + .../meta-precedence/template.html | 14 + .../meta-precedence/test.ts | 116 +++++++ .../consistent-sampling/meta/init.js | 18 + .../consistent-sampling/meta/subject.js | 17 + .../consistent-sampling/meta/template.html | 13 + .../consistent-sampling/meta/test.ts | 171 ++++++++++ .../tracesSampler-precedence/init.js | 29 ++ .../tracesSampler-precedence/subject.js | 17 + .../tracesSampler-precedence/template.html | 8 + .../tracesSampler-precedence/test.ts | 152 +++++++++ .../custom-trace/subject.js | 14 + .../custom-trace/template.html | 8 + .../custom-trace/test.ts | 63 ++++ .../linked-traces-streamed/default/test.ts | 95 ++++++ .../linked-traces-streamed/init.js | 10 + .../interaction-spans/init.js | 12 + .../interaction-spans/template.html | 7 + .../interaction-spans/test.ts | 79 +++++ .../linked-traces-streamed/meta/template.html | 11 + .../linked-traces-streamed/meta/test.ts | 50 +++ .../negatively-sampled/init.js | 15 + .../negatively-sampled/test.ts | 44 +++ .../session-storage/init.js | 12 + .../session-storage/test.ts | 42 +++ .../init.js | 18 + .../subject.js | 18 + .../template.html | 11 + .../test.ts | 25 ++ .../assets/script.js | 12 + .../init.js | 12 + .../template.html | 10 + .../test.ts | 28 ++ .../assets/script.js | 25 ++ .../init.js | 16 + .../template.html | 11 + .../test.ts | 109 ++++++ .../assets/script.js | 25 ++ .../init.js | 16 + .../template.html | 11 + .../test.ts | 111 ++++++ .../init.js | 19 ++ .../subject.js | 17 + .../template.html | 12 + .../test.ts | 29 ++ .../assets/script.js | 12 + .../long-tasks-disabled-streamed/init.js | 12 + .../template.html | 10 + .../long-tasks-disabled-streamed/test.ts | 23 ++ .../assets/script.js | 12 + .../long-tasks-enabled-streamed/init.js | 15 + .../long-tasks-enabled-streamed/template.html | 10 + .../long-tasks-enabled-streamed/test.ts | 42 +++ .../navigation-streamed/init.js | 9 + .../navigation-streamed/test.ts | 219 ++++++++++++ .../pageload-streamed/init.js | 11 + .../pageload-streamed/test.ts | 131 ++++++++ .../reportPageLoaded-streamed/default/init.js | 15 + .../reportPageLoaded-streamed/default/test.ts | 41 +++ .../finalTimeout/init.js | 16 + .../finalTimeout/test.ts | 40 +++ .../navigation/init.js | 22 ++ .../navigation/test.ts | 38 +++ .../tracing/linking-addLink-streamed/init.js | 9 + .../linking-addLink-streamed/subject.js | 28 ++ .../tracing/linking-addLink-streamed/test.ts | 66 ++++ .../tracing/request/fetch-streamed/init.js | 11 + .../tracing/request/fetch-streamed/subject.js | 5 + .../tracing/request/fetch-streamed/test.ts | 43 +++ .../tracing/request/xhr-streamed/init.js | 11 + .../tracing/request/xhr-streamed/subject.js | 12 + .../tracing/request/xhr-streamed/test.ts | 43 +++ .../setSpanActive-streamed/default/init.js | 9 + .../setSpanActive-streamed/default/subject.js | 14 + .../setSpanActive-streamed/default/test.ts | 35 ++ .../nested-parentAlwaysRoot/init.js | 10 + .../nested-parentAlwaysRoot/subject.js | 22 ++ .../nested-parentAlwaysRoot/test.ts | 63 ++++ .../setSpanActive-streamed/nested/init.js | 9 + .../setSpanActive-streamed/nested/subject.js | 22 ++ .../setSpanActive-streamed/nested/test.ts | 58 ++++ .../navigation-streamed/init.js | 12 + .../navigation-streamed/test.ts | 318 ++++++++++++++++++ .../trace-lifetime/pageload-streamed/init.js | 12 + .../trace-lifetime/pageload-streamed/test.ts | 238 +++++++++++++ .../startNewTrace-streamed/init.js | 10 + .../startNewTrace-streamed/subject.js | 15 + .../startNewTrace-streamed/template.html | 10 + .../startNewTrace-streamed/test.ts | 44 +++ .../utils/helpers.ts | 2 +- .../utils/spanUtils.ts | 133 ++++++++ .../browser/src/integrations/spanstreaming.ts | 11 +- .../test/integrations/spanstreaming.test.ts | 27 +- packages/core/src/index.ts | 1 + packages/core/src/utils/spanUtils.ts | 7 +- 121 files changed, 4504 insertions(+), 6 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/utils/spanUtils.ts diff --git a/.size-limit.js b/.size-limit.js index bed4d677b736..44e701b3466b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -148,7 +148,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '45 KB', + limit: '46 KB', }, // Vue SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js new file mode 100644 index 000000000000..aaafd3396f14 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1.0, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js new file mode 100644 index 000000000000..7e4395e06708 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js @@ -0,0 +1,13 @@ +Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); + + const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' }); + inactiveSpan.end(); + + Sentry.startSpanManual({ name: 'test-manual-span' }, span => { + // noop + span.end(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts new file mode 100644 index 000000000000..b5f8f41ab4b4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -0,0 +1,217 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest( + 'sends a streamed span envelope if spanStreamingIntegration is enabled', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spanEnvelope = await spanEnvelopePromise; + + const envelopeHeader = spanEnvelope[0]; + const envelopeItem = spanEnvelope[1]; + const spans = envelopeItem[0][1].items; + + expect(envelopeHeader).toEqual({ + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + transaction: 'test-span', + }, + }); + + const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!); + const traceId = envelopeHeader.trace!.trace_id; + + expect(Number.isNaN(numericSampleRand)).toBe(false); + + expect(envelopeItem).toEqual([ + [ + { content_type: 'application/vnd.sentry.items.span.v2+json', item_count: 4, type: 'span' }, + { + items: expect.any(Array), + }, + ], + ]); + + const segmentSpanId = spans.find(s => !!s.is_segment)?.span_id; + expect(segmentSpanId).toBeDefined(); + + expect(spans).toEqual([ + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-child-span', + parent_span_id: segmentSpanId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-inactive-span', + parent_span_id: segmentSpanId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-manual-span', + parent_span_id: segmentSpanId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: segmentSpanId, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: 'test-span', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'custom', + }, + 'sentry.span.source': { + type: 'string', + value: 'custom', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'test-span', + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }, + ]); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js new file mode 100644 index 000000000000..749560a5c459 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js new file mode 100644 index 000000000000..b657f38ac009 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js @@ -0,0 +1,8 @@ +document.getElementById('go-background').addEventListener('click', () => { + setTimeout(() => { + Object.defineProperty(document, 'hidden', { value: true, writable: true }); + const ev = document.createEvent('Event'); + ev.initEvent('visibilitychange'); + document.dispatchEvent(ev); + }, 250); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html new file mode 100644 index 000000000000..8083ddc80694 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts new file mode 100644 index 000000000000..10e58acb81ad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts @@ -0,0 +1,18 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest('finishes streamed pageload span when the page goes background', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + const url = await getLocalTestUrl({ testDir: __dirname }); + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + await page.locator('#go-background').click(); + const pageloadSpan = await pageloadSpanPromise; + + // TODO: Is this what we want? + expect(pageloadSpan.status).toBe('ok'); + expect(pageloadSpan.attributes?.['sentry.cancellation_reason']?.value).toBe('document.hidden'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js new file mode 100644 index 000000000000..7eff1a54e9ff --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + _experiments: { + enableHTTPTimings: true, + }, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + traceLifecycle: 'stream', + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js new file mode 100644 index 000000000000..e19cc07e28f5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js @@ -0,0 +1,3 @@ +fetch('http://sentry-test-site.example/0').then( + fetch('http://sentry-test-site.example/1').then(fetch('http://sentry-test-site.example/2')), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts new file mode 100644 index 000000000000..ffa63937bf32 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts @@ -0,0 +1,71 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +/** + * This test details a limitation of span streaming in comparison to transaction-based tracing: + * We can no longer attach http PerformanceResourceTiming attributes to http.client spans in + * span streaming mode. The reason is that we track `http.client` spans in real time but only + * get the detailed timing information after the span already ended. + * We can probably fix this (somehat at least) but will do so in a follow-up PR. + * @see https://github.com/getsentry/sentry-javascript/issues/19613 + */ +sentryTest( + "[limitation] doesn't add http timing to http.client spans in span streaming mode", + async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + sentryTest.skip(shouldSkipTracingTest() || !supportedBrowsers.includes(browserName) || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', async route => { + const request = route.request(); + const postData = await request.postDataJSON(); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Object.assign({ id: 1 }, postData)), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'http.client')); + await page.goto(url); + + const requestSpans = (await spansPromise).filter(s => getSpanOp(s) === 'http.client'); + const pageloadSpan = (await spansPromise).find(s => getSpanOp(s) === 'pageload'); + + expect(pageloadSpan).toBeDefined(); + expect(requestSpans).toHaveLength(3); + + requestSpans?.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + status: 'ok', + attributes: expect.not.objectContaining({ + 'http.request.redirect_start': expect.any(Object), + 'http.request.redirect_end': expect.any(Object), + 'http.request.worker_start': expect.any(Object), + 'http.request.fetch_start': expect.any(Object), + 'http.request.domain_lookup_start': expect.any(Object), + 'http.request.domain_lookup_end': expect.any(Object), + 'http.request.connect_start': expect.any(Object), + 'http.request.secure_connection_start': expect.any(Object), + 'http.request.connection_end': expect.any(Object), + 'http.request.request_start': expect.any(Object), + 'http.request.response_start': expect.any(Object), + 'http.request.response_end': expect.any(Object), + 'http.request.time_to_first_byte': expect.any(Object), + 'network.protocol.version': expect.any(Object), + }), + }), + ); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js new file mode 100644 index 000000000000..385e9ed6b6cf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + enableLongTask: false, + _experiments: { + enableInteractions: true, + }, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js new file mode 100644 index 000000000000..ff9057926396 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js @@ -0,0 +1,16 @@ +const blockUI = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 70) { + // + } + + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=interaction-button]').addEventListener('click', blockUI); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html new file mode 100644 index 000000000000..64e944054632 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html @@ -0,0 +1,14 @@ + + + + + + +
Rendered Before Long Task
+ + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts new file mode 100644 index 000000000000..fd384d0d3ff9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts @@ -0,0 +1,134 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('captures streamed interaction span tree. @firefox', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + sentryTest.skip(shouldSkipTracingTest() || !supportedBrowsers.includes(browserName) || testingCdnBundle()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + const interactionSpansPromise = waitForStreamedSpans(page, spans => + spans.some(span => getSpanOp(span) === 'ui.action.click'), + ); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + // wait for pageload span to finish before clicking the interaction button + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('[data-test-id=interaction-button]').click(); + await page.locator('.clicked[data-test-id=interaction-button]').isVisible(); + + const interactionSpanTree = await interactionSpansPromise; + + const interactionSegmentSpan = interactionSpanTree.find(span => !!span.is_segment); + + expect(interactionSegmentSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'ui.action.click', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', // TODO: This is incorrect but not from span streaming. + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: interactionSegmentSpan!.span_id, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: '/index.html', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: '/index.html', + span_id: interactionSegmentSpan!.span_id, + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: pageloadSpan.trace_id, // same trace id as pageload + }); + + const loAFSpans = interactionSpanTree.filter(span => getSpanOp(span)?.startsWith('ui.long-animation-frame')); + expect(loAFSpans).toHaveLength(1); + + const interactionSpan = interactionSpanTree.find(span => getSpanOp(span) === 'ui.interaction.click'); + expect(interactionSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'ui.interaction.click', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.ui.browser.metrics', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: interactionSegmentSpan!.span_id, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: '/index.html', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'body > button.clicked', + parent_span_id: interactionSegmentSpan!.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: pageloadSpan.trace_id, // same trace id as pageload + }); + + const interactionSpanDuration = (interactionSpan!.end_timestamp - interactionSpan!.start_timestamp) * 1000; + expect(interactionSpanDuration).toBeGreaterThan(65); + expect(interactionSpanDuration).toBeLessThan(200); + expect(interactionSpan?.status).toBe('ok'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js new file mode 100644 index 000000000000..63afee65329a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampler: ctx => { + if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 1; + } + return ctx.inheritOrSampleWith(0); + }, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html new file mode 100644 index 000000000000..f26a602c7c6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts new file mode 100644 index 000000000000..a97e13a4890a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData, parseBaggageHeader, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +sentryTest.describe('When `consistentTraceSampling` is `true`', () => { + sentryTest('continues sampling decision from initial pageload span', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const pageloadSampleRand = Number(envelope[0].trace?.sample_rand); + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1); + expect(Number.isNaN(pageloadSampleRand)).toBe(false); + expect(pageloadSampleRand).toBeGreaterThanOrEqual(0); + expect(pageloadSampleRand).toBeLessThanOrEqual(1); + + return { pageloadSpan, pageloadSampleRand }; + }); + + const customTraceSpan = await sentryTest.step('Custom trace', async () => { + const customEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + await page.locator('#btn1').click(); + const envelope = await customEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!; + + expect(span.trace_id).not.toEqual(pageloadSpan.trace_id); + // although we "continue the trace" from pageload, this is actually a root span, + // so there must not be a parent span id + expect(span.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand); + + return span; + }); + + await sentryTest.step('Navigation', async () => { + const navigationEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#foo`); + const envelope = await navigationEnvelopePromise; + const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id); + expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(navSpan.links).toEqual([ + { + trace_id: customTraceSpan.trace_id, + span_id: customTraceSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + expect(navSpan.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand); + }); + }); + + sentryTest('Propagates continued sampling decision to outgoing requests', async ({ page, getLocalTestUrl }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const pageloadSampleRand = Number(envelope[0].trace?.sample_rand); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand); + expect(pageloadSampleRand).toBeGreaterThanOrEqual(0); + expect(pageloadSampleRand).toBeLessThanOrEqual(1); + expect(Number.isNaN(pageloadSampleRand)).toBe(false); + + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1); + + return { pageloadSpan, pageloadSampleRand }; + }); + + await sentryTest.step('Make fetch request', async () => { + const fetchEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + const fetchEnvelope = await fetchEnvelopePromise; + + const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand); + const fetchTraceSpans = fetchEnvelope[1][0][1].items; + const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!; + const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client'); + + expect(fetchTraceSampleRand).toBe(pageloadSampleRand); + + expect(fetchTraceSpan.attributes?.['sentry.sample_rate']?.value).toEqual( + pageloadSpan.attributes?.['sentry.sample_rate']?.value, + ); + expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: fetchTraceSpan.trace_id, + parentSpanId: httpClientSpan?.span_id, + parentSampled: true, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${pageloadSampleRand}`, + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-trace_id': fetchTraceSpan.trace_id, + 'sentry-transaction': 'custom root span 2', + }); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js new file mode 100644 index 000000000000..d570ac45144c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + traceLifecycle: 'stream', + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampleRate: 1, + debug: true, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html new file mode 100644 index 000000000000..6347fa37fc00 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts new file mode 100644 index 000000000000..73b4bea99e22 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts @@ -0,0 +1,97 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core/src'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, + waitForTracingHeadersOnUrl, +} from '../../../../../../utils/helpers'; +import { observeStreamedSpan } from '../../../../../../utils/spanUtils'; + +const metaTagSampleRand = 0.9; +const metaTagSampleRate = 0.2; +const metaTagTraceId = '12345678901234567890123456789012'; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest( + 'Continues negative sampling decision from meta tag across all traces and downstream propagations', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansReceived: SerializedStreamedSpan[] = []; + observeStreamedSpan(page, span => { + spansReceived.push(span); + return false; + }); + + const clientReportPromise = waitForClientReportRequest(page); + + await sentryTest.step('Initial pageload', async () => { + await page.goto(url); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Custom instrumented button click', async () => { + await page.locator('#btn1').click(); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Navigation', async () => { + await page.goto(`${url}#foo`); + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Make fetch request', async () => { + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + const { baggage, sentryTrace } = await tracingHeadersPromise; + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: expect.not.stringContaining(metaTagTraceId), + parentSpanId: expect.stringMatching(/^[\da-f]{16}$/), + parentSampled: false, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'false', + 'sentry-trace_id': expect.not.stringContaining(metaTagTraceId), + 'sentry-transaction': 'custom root span 2', + }); + + expect(spansReceived).toHaveLength(0); + }); + + await sentryTest.step('Client report', async () => { + await hidePage(page); + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 4, + reason: 'sample_rate', + }, + ], + }); + }); + + expect(spansReceived).toHaveLength(0); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js new file mode 100644 index 000000000000..177fe4c4aeaf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'session-storage', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampler: ({ inheritOrSampleWith }) => { + return inheritOrSampleWith(0); + }, + debug: true, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html new file mode 100644 index 000000000000..9a0719b7e505 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html @@ -0,0 +1,15 @@ + + + + + + + + +

Another Page

+ Go To the next page + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html new file mode 100644 index 000000000000..27cd47bba7c1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html @@ -0,0 +1,10 @@ + + + + + + +

Another Page

+ Go To the next page + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js new file mode 100644 index 000000000000..ec0264fa49ef --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1?.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2?.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html new file mode 100644 index 000000000000..eab1fecca6c4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html @@ -0,0 +1,14 @@ + + + + + + + + Go To another page + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts new file mode 100644 index 000000000000..4cafe023b57d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts @@ -0,0 +1,116 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, + waitForTracingHeadersOnUrl, +} from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +const metaTagSampleRand = 0.9; +const metaTagSampleRate = 0.2; +const metaTagTraceIdIndex = '12345678901234567890123456789012'; +const metaTagTraceIdPage1 = 'a2345678901234567890123456789012'; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest( + 'meta tag decision has precedence over sampling decision from previous trace in session storage', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clientReportPromise = waitForClientReportRequest(page); + + await sentryTest.step('Initial pageload', async () => { + // negative sampling decision -> no pageload span + await page.goto(url); + }); + + await sentryTest.step('Make fetch request', async () => { + // The fetch requests starts a new trace on purpose. So we only want the + // sampling decision and rand to be the same as from the meta tag but not the trace id or DSC + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: expect.not.stringContaining(metaTagTraceIdIndex), + parentSpanId: expect.stringMatching(/^[\da-f]{16}$/), + parentSampled: false, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'false', + 'sentry-trace_id': expect.not.stringContaining(metaTagTraceIdIndex), + 'sentry-transaction': 'custom root span 2', + }); + }); + + await sentryTest.step('Client report', async () => { + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 2, + reason: 'sample_rate', + }, + ], + }); + }); + + await sentryTest.step('Navigate to another page with meta tags', async () => { + const page1PageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload' && s.trace_id === metaTagTraceIdPage1), + ); + await page.locator('a').click(); + + const envelope = await page1PageloadEnvelopePromise; + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12); + expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2); + expect(pageloadSpan.trace_id).toEqual(metaTagTraceIdPage1); + }); + + await sentryTest.step('Navigate to another page without meta tags', async () => { + const page2PageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => + !!env[1][0][1].items.find( + s => + getSpanOp(s) === 'pageload' && s.trace_id !== metaTagTraceIdPage1 && s.trace_id !== metaTagTraceIdIndex, + ), + ); + await page.locator('a').click(); + + const envelope = await page2PageloadEnvelopePromise; + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12); + expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2); + expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdPage1); + expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdIndex); + }); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js new file mode 100644 index 000000000000..a1ddc5465950 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + // only take into account sampling from meta tag; otherwise sample negatively + tracesSampleRate: 0, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html new file mode 100644 index 000000000000..7ceca6fec2a3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts new file mode 100644 index 000000000000..08cee9111b8a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts @@ -0,0 +1,171 @@ +import { expect } from '@playwright/test'; +import { + extractTraceparentData, + parseBaggageHeader, + SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, +} from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +const metaTagSampleRand = 0.051121; +const metaTagSampleRate = 0.2; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest('Continues sampling decision across all traces from meta tag', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(span.attributes?.['sentry.sample_rate']).toBeUndefined(); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined(); + + return span; + }); + + const customTraceSpan = await sentryTest.step('Custom trace', async () => { + const customEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + + await page.locator('#btn1').click(); + + const envelope = await customEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!; + + expect(span.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(span.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate); + expect(envelope[0].trace?.sampled).toBe('true'); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(span.attributes?.['sentry.sample_rate']).toBeUndefined(); + + // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header) + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate); + + return span; + }); + + await sentryTest.step('Navigation', async () => { + const navigationEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + + await page.goto(`${url}#foo`); + + const envelope = await navigationEnvelopePromise; + const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id); + + expect(navSpan.parent_span_id).toBeUndefined(); + + expect(Number(envelope[0].trace?.sample_rand)).toEqual(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toEqual(metaTagSampleRate); + expect(envelope[0].trace?.sampled).toEqual('true'); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(navSpan.attributes?.['sentry.sample_rate']).toBeUndefined(); + + // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header) + expect(navSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate); + }); + }); + + sentryTest( + 'Propagates continued tag sampling decision to outgoing requests', + async ({ page, getLocalTestUrl }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(span.attributes?.['sentry.sample_rate']).toBeUndefined(); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined(); + + return span; + }); + + await sentryTest.step('Make fetch request', async () => { + const fetchEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + const fetchEnvelope = await fetchEnvelopePromise; + + const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand); + const fetchTraceSpans = fetchEnvelope[1][0][1].items; + const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!; + const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client'); + + expect(fetchTraceSampleRand).toEqual(metaTagSampleRand); + + expect(fetchTraceSpan.attributes?.['sentry.sample_rate']).toBeUndefined(); + expect(fetchTraceSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe( + metaTagSampleRate, + ); + + expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: fetchTraceSpan.trace_id, + parentSpanId: httpClientSpan?.span_id, + parentSampled: true, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'true', + 'sentry-trace_id': fetchTraceSpan.trace_id, + 'sentry-transaction': 'custom root span 2', + }); + }); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js new file mode 100644 index 000000000000..623db0ecc028 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + enableInp: false, + }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['sentry-test-external.io'], + tracesSampler: ctx => { + if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 1; + } + if (ctx.name === 'custom root span 1') { + return 0; + } + if (ctx.name === 'custom root span 2') { + return 1; + } + return ctx.inheritOrSampleWith(0); + }, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js new file mode 100644 index 000000000000..de60904fab3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('http://sentry-test-external.io'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html new file mode 100644 index 000000000000..f26a602c7c6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts new file mode 100644 index 000000000000..46805496a676 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts @@ -0,0 +1,152 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser'; +import type { ClientReport } from '@sentry/core'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, +} from '../../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; + +/** + * This test demonstrates that: + * - explicit sampling decisions in `tracesSampler` has precedence over consistent sampling + * - despite consistentTraceSampling being activated, there are still a lot of cases where the trace chain can break + */ +sentryTest.describe('When `consistentTraceSampling` is `true`', () => { + sentryTest('explicit sampling decisions in `tracesSampler` have precedence', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadSpan } = await sentryTest.step('Initial pageload', async () => { + const pageloadEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + await page.goto(url); + + const envelope = await pageloadEnvelopePromise; + const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!; + + expect(pageloadSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1); + expect(Number(envelope[0].trace?.sample_rand)).toBeGreaterThanOrEqual(0); + + return { pageloadSpan }; + }); + + await sentryTest.step('Custom trace is sampled negatively (explicitly in tracesSampler)', async () => { + const clientReportPromise = waitForClientReportRequest(page); + + await page.locator('#btn1').click(); + + await page.waitForTimeout(500); + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }); + }); + + await sentryTest.step('Subsequent navigation trace is also sampled negatively', async () => { + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(`${url}#foo`); + + await page.waitForTimeout(500); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }); + }); + + const { customTrace2Span } = await sentryTest.step( + 'Custom trace 2 is sampled positively (explicitly in tracesSampler)', + async () => { + const customEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'), + ); + + await page.locator('#btn2').click(); + + const envelope = await customEnvelopePromise; + const customTrace2Span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!; + + expect(customTrace2Span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1); + expect(customTrace2Span.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(customTrace2Span.parent_span_id).toBeUndefined(); + + expect(customTrace2Span.links).toEqual([ + { + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + sampled: false, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + ]); + + return { customTrace2Span }; + }, + ); + + await sentryTest.step('Navigation trace is sampled positively (inherited from previous trace)', async () => { + const navigationEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => env[0].trace?.sampled === 'true' && !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + + await page.goto(`${url}#bar`); + + const envelope = await navigationEnvelopePromise; + const navigationSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(navigationSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1); + expect(navigationSpan.trace_id).not.toEqual(customTrace2Span.trace_id); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + expect(navigationSpan.links).toEqual([ + { + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + sampled: true, + span_id: customTrace2Span.span_id, + trace_id: customTrace2Span.trace_id, + }, + ]); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js new file mode 100644 index 000000000000..2a929a7e5083 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js @@ -0,0 +1,14 @@ +const btn1 = document.getElementById('btn1'); +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, () => {}); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html new file mode 100644 index 000000000000..f26a602c7c6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts new file mode 100644 index 000000000000..d6e45901f959 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest('manually started custom traces are linked correctly in the chain', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + return pageloadSpanPromise; + }); + + const customTraceSpan = await sentryTest.step('Custom trace', async () => { + const customSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'custom'); + await page.locator('#btn1').click(); + const span = await customSpanPromise; + + expect(span.trace_id).not.toEqual(pageloadSpan.trace_id); + expect(span.links).toEqual([ + { + trace_id: pageloadSpan.trace_id, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + return span; + }); + + await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navSpan = await navigationSpanPromise; + + expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id); + expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + + expect(navSpan.links).toEqual([ + { + trace_id: customTraceSpan.trace_id, + span_id: customTraceSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts new file mode 100644 index 000000000000..80e500437f79 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts @@ -0,0 +1,95 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageloadSpan = await pageloadSpanPromise; + + const navigation1SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navigation1Span = await navigation1SpanPromise; + + const navigation2SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#bar`); + const navigation2Span = await navigation2SpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigation1TraceId = navigation1Span.trace_id; + const navigation2TraceId = navigation2Span.trace_id; + + expect(pageloadSpan.links).toBeUndefined(); + + expect(navigation1Span.links).toEqual([ + { + trace_id: pageloadTraceId, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigation1Span.attributes?.['sentry.previous_trace']).toEqual({ + type: 'string', + value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`, + }); + + expect(navigation2Span.links).toEqual([ + { + trace_id: navigation1TraceId, + span_id: navigation1Span.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigation2Span.attributes?.['sentry.previous_trace']).toEqual({ + type: 'string', + value: `${navigation1TraceId}-${navigation1Span.span_id}-1`, + }); + + expect(pageloadTraceId).not.toEqual(navigation1TraceId); + expect(navigation1TraceId).not.toEqual(navigation2TraceId); + expect(pageloadTraceId).not.toEqual(navigation2TraceId); +}); + +sentryTest("doesn't link between hard page reloads by default", async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await sentryTest.step('First pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageload1Span = await pageloadSpanPromise; + + expect(pageload1Span).toBeDefined(); + expect(pageload1Span.links).toBeUndefined(); + }); + + await sentryTest.step('Second pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.reload(); + const pageload2Span = await pageloadSpanPromise; + + expect(pageload2Span).toBeDefined(); + expect(pageload2Span.links).toBeUndefined(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js new file mode 100644 index 000000000000..749560a5c459 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js new file mode 100644 index 000000000000..f07f76ecd692 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [ + Sentry.browserTracingIntegration({ _experiments: { enableInteractions: true } }), + Sentry.spanStreamingIntegration(), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html new file mode 100644 index 000000000000..7f6845239468 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts new file mode 100644 index 000000000000..c34aba99dbdd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts @@ -0,0 +1,79 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +/* + This is quite peculiar behavior but it's a result of the route-based trace lifetime. + Once we shortened trace lifetime, this whole scenario will change as the interaction + spans will be their own trace. So most likely, we can replace this test with a new one + that covers the new default behavior. +*/ +sentryTest( + 'only the first root spans in the trace link back to the previous trace', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const span = await pageloadSpanPromise; + + expect(span).toBeDefined(); + expect(span.links).toBeUndefined(); + + return span; + }); + + await sentryTest.step('Click Before navigation', async () => { + const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click'); + await page.click('#btn'); + const interactionSpan = await interactionSpanPromise; + + // sanity check: route-based trace lifetime means the trace_id should be the same + expect(interactionSpan.trace_id).toBe(pageloadSpan.trace_id); + + // no links yet as previous root span belonged to same trace + expect(interactionSpan.links).toBeUndefined(); + }); + + const navigationSpan = await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const span = await navigationSpanPromise; + + expect(getSpanOp(span)).toBe('navigation'); + expect(span.links).toEqual([ + { + trace_id: pageloadSpan.trace_id, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(span.trace_id).not.toEqual(span.links![0].trace_id); + return span; + }); + + await sentryTest.step('Click After navigation', async () => { + const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click'); + await page.click('#btn'); + const interactionSpan = await interactionSpanPromise; + + // sanity check: route-based trace lifetime means the trace_id should be the same + expect(interactionSpan.trace_id).toBe(navigationSpan.trace_id); + + // since this is the second root span in the trace, it doesn't link back to the previous trace + expect(interactionSpan.links).toBeUndefined(); + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html new file mode 100644 index 000000000000..2221bd0fee1d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts new file mode 100644 index 000000000000..cbcc231593ea --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + "links back to previous trace's local root span if continued from meta tags", + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const metaTagTraceId = '12345678901234567890123456789012'; + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const span = await pageloadSpanPromise; + + // sanity check + expect(span.trace_id).toBe(metaTagTraceId); + expect(span.links).toBeUndefined(); + + return span; + }); + + const navigationSpan = await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + return navigationSpanPromise; + }); + + expect(navigationSpan.links).toEqual([ + { + trace_id: metaTagTraceId, + span_id: pageloadSpan.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigationSpan.trace_id).not.toEqual(metaTagTraceId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js new file mode 100644 index 000000000000..778092cf026b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + // We want to ignore redirects for this test + integrations: [Sentry.browserTracingIntegration({ detectRedirects: false }), Sentry.spanStreamingIntegration()], + tracesSampler: ctx => { + if (ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 0; + } + return 1; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts new file mode 100644 index 000000000000..06366eb9921a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest('includes a span link to a previously negatively sampled span', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await sentryTest.step('Initial pageload', async () => { + // No span envelope expected here because this pageload span is sampled negatively! + await page.goto(url); + }); + + await sentryTest.step('Navigation', async () => { + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navigationSpan = await navigationSpanPromise; + + expect(getSpanOp(navigationSpan)).toBe('navigation'); + expect(navigationSpan.links).toEqual([ + { + trace_id: expect.stringMatching(/[a-f\d]{32}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + sampled: false, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(navigationSpan.attributes?.['sentry.previous_trace']).toEqual({ + type: 'string', + value: expect.stringMatching(/[a-f\d]{32}-[a-f\d]{16}-0/), + }); + + expect(navigationSpan.trace_id).not.toEqual(navigationSpan.links![0].trace_id); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js new file mode 100644 index 000000000000..e51af56c2a9d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ linkPreviousTrace: 'session-storage' }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts new file mode 100644 index 000000000000..96a5bbeacc6d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest('adds link between hard page reloads when opting into sessionStorage', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageload1Span = await sentryTest.step('First pageload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const span = await pageloadSpanPromise; + expect(span).toBeDefined(); + expect(span.links).toBeUndefined(); + return span; + }); + + const pageload2Span = await sentryTest.step('Hard page reload', async () => { + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.reload(); + return pageloadSpanPromise; + }); + + expect(pageload2Span.links).toEqual([ + { + trace_id: pageload1Span.trace_id, + span_id: pageload1Span.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + type: 'string', + value: 'previous_trace', + }, + }, + }, + ]); + + expect(pageload1Span.trace_id).not.toEqual(pageload2Span.trace_id); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js new file mode 100644 index 000000000000..ee197adaa33c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongTask: false, + enableLongAnimationFrame: true, + instrumentPageLoad: false, + enableInp: false, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js new file mode 100644 index 000000000000..b02ed6efa33b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js @@ -0,0 +1,18 @@ +function getElapsed(startTime) { + const time = Date.now(); + return time - startTime; +} + +function handleClick() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } + window.history.pushState({}, '', `#myHeading`); +} + +const button = document.getElementById('clickme'); + +console.log('button', button); + +button.addEventListener('click', handleClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html new file mode 100644 index 000000000000..6a6a89752f20 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html @@ -0,0 +1,11 @@ + + + + + + + + +

My Heading

+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts new file mode 100644 index 000000000000..3054c1c84bcb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts @@ -0,0 +1,25 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + "doesn't capture long animation frame that starts before a navigation.", + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation')); + + await page.goto(url); + + await page.locator('#clickme').click(); + + const spans = await navigationSpansPromise; + + const loafSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + expect(loafSpans).toHaveLength(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js new file mode 100644 index 000000000000..195a094070be --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 101) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js new file mode 100644 index 000000000000..965613d5464e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html new file mode 100644 index 000000000000..62aed26413f8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Animation Frame
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts new file mode 100644 index 000000000000..7ba1dddd0c90 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts @@ -0,0 +1,28 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'does not capture long animation frame when flag is disabled.', + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + + expect(uiSpans.length).toBe(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js new file mode 100644 index 000000000000..10552eeb5bd5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js @@ -0,0 +1,25 @@ +function getElapsed(startTime) { + const time = Date.now(); + return time - startTime; +} + +function handleClick() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +function start() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +// trigger 2 long-animation-frame events +// one from the top-level and the other from an event-listener +start(); + +const button = document.getElementById('clickme'); +button.addEventListener('click', handleClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js new file mode 100644 index 000000000000..1f6cc0a8f463 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongTask: false, + enableLongAnimationFrame: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html new file mode 100644 index 000000000000..c157aa80cb8d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html @@ -0,0 +1,11 @@ + + + + + + +
Rendered Before Long Animation Frame
+ + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts new file mode 100644 index 000000000000..c1e7efa5e8d8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts @@ -0,0 +1,109 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'captures long animation frame span for top-level script.', + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(1); + + const topLevelUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js', + )!; + + expect(topLevelUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + 'browser.script.source_char_position': expect.objectContaining({ value: 0 }), + 'browser.script.invoker': { + type: 'string', + value: 'https://sentry-test-site.example/path/to/script.js', + }, + 'browser.script.invoker_type': { type: 'string', value: 'classic-script' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = topLevelUISpan.start_timestamp ?? 0; + const end = topLevelUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); + }, +); + +sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + // trigger long animation frame function + await page.getByRole('button').click(); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(2); + + const eventListenerUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick', + )!; + + expect(eventListenerUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' }, + 'browser.script.invoker_type': { type: 'string', value: 'event-listener' }, + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = eventListenerUISpan.start_timestamp ?? 0; + const end = eventListenerUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js new file mode 100644 index 000000000000..10552eeb5bd5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js @@ -0,0 +1,25 @@ +function getElapsed(startTime) { + const time = Date.now(); + return time - startTime; +} + +function handleClick() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +function start() { + const startTime = Date.now(); + while (getElapsed(startTime) < 105) { + // + } +} + +// trigger 2 long-animation-frame events +// one from the top-level and the other from an event-listener +start(); + +const button = document.getElementById('clickme'); +button.addEventListener('click', handleClick); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js new file mode 100644 index 000000000000..3e3eedaf49b7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongTask: true, + enableLongAnimationFrame: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html new file mode 100644 index 000000000000..c157aa80cb8d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html @@ -0,0 +1,11 @@ + + + + + + +
Rendered Before Long Animation Frame
+ + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts new file mode 100644 index 000000000000..4f9207fa1e34 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts @@ -0,0 +1,111 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'captures long animation frame span for top-level script.', + async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + // Long animation frame should take priority over long tasks + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(1); + + const topLevelUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js', + )!; + + expect(topLevelUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + 'browser.script.source_char_position': expect.objectContaining({ value: 0 }), + 'browser.script.invoker': { + type: 'string', + value: 'https://sentry-test-site.example/path/to/script.js', + }, + 'browser.script.invoker_type': { type: 'string', value: 'classic-script' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = topLevelUISpan.start_timestamp ?? 0; + const end = topLevelUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); + }, +); + +sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => { + // Long animation frames only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + // trigger long animation frame function + await page.getByRole('button').click(); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame')); + + expect(uiSpans.length).toBeGreaterThanOrEqual(2); + + const eventListenerUISpan = uiSpans.find( + s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick', + )!; + + expect(eventListenerUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' }, + 'browser.script.invoker_type': { type: 'string', value: 'event-listener' }, + 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' }, + }), + }), + ); + + const start = eventListenerUISpan.start_timestamp ?? 0; + const end = eventListenerUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js new file mode 100644 index 000000000000..f6e5ce777e06 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongAnimationFrame: false, + instrumentPageLoad: false, + instrumentNavigation: true, + enableInp: false, + enableLongTask: true, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js new file mode 100644 index 000000000000..d814f8875715 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js @@ -0,0 +1,17 @@ +const longTaskButton = document.getElementById('myButton'); + +longTaskButton?.addEventListener('click', () => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 500) { + // + } + + // trigger a navigation in the same event loop tick + window.history.pushState({}, '', '#myHeading'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html new file mode 100644 index 000000000000..c2cb2a8129fe --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + +

Heading

+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts new file mode 100644 index 000000000000..74ce32706584 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts @@ -0,0 +1,29 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + "doesn't capture long task spans starting before a navigation in the navigation transaction", + async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/path/to/script.js', route => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation')); + + await page.goto(url); + + await page.locator('#myButton').click(); + + const spans = await navigationSpansPromise; + + const navigationSpan = spans.find(s => getSpanOp(s) === 'navigation'); + expect(navigationSpan).toBeDefined(); + + const longTaskSpans = spans.filter(s => getSpanOp(s) === 'ui.long-task'); + expect(longTaskSpans).toHaveLength(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js new file mode 100644 index 000000000000..195a094070be --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 101) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js new file mode 100644 index 000000000000..965613d5464e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html new file mode 100644 index 000000000000..b03231da2c65 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Task
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts new file mode 100644 index 000000000000..83600f5d4a6a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts @@ -0,0 +1,23 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest("doesn't capture long task spans when flag is disabled.", async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + + expect(uiSpans.length).toBe(0); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js new file mode 100644 index 000000000000..b61592e05943 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js @@ -0,0 +1,12 @@ +(() => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 105) { + // + } +})(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js new file mode 100644 index 000000000000..484350c14fcf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 2000, + enableLongAnimationFrame: false, + }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html new file mode 100644 index 000000000000..b03231da2c65 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + +
Rendered Before Long Task
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts new file mode 100644 index 000000000000..8b73aa91dff6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts @@ -0,0 +1,42 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) => { + // Long tasks only work on chrome + sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle()); + + await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!; + + const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui')); + expect(uiSpans.length).toBeGreaterThan(0); + + const [firstUISpan] = uiSpans; + expect(firstUISpan).toEqual( + expect.objectContaining({ + name: 'Main UI thread blocked', + parent_span_id: pageloadSpan.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.long-task' }, + }), + }), + ); + + const start = firstUISpan.start_timestamp ?? 0; + const end = firstUISpan.end_timestamp ?? 0; + const duration = end - start; + + expect(duration).toBeGreaterThanOrEqual(0.1); + expect(duration).toBeLessThanOrEqual(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js new file mode 100644 index 000000000000..a93fc742bafb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts new file mode 100644 index 000000000000..7128d2d5ecce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -0,0 +1,219 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { + getSpanOp, + getSpansFromEnvelope, + waitForStreamedSpan, + waitForStreamedSpanEnvelope, +} from '../../../../utils/spanUtils'; + +sentryTest('starts a streamed navigation span on page navigation', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'navigation'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + // simulate navigation + page.goto(`${url}#foo`); + + const navigationSpanEnvelope = await navigationSpanEnvelopePromise; + + const navigationSpanEnvelopeHeader = navigationSpanEnvelope[0]; + const navigationSpanEnvelopeItem = navigationSpanEnvelope[1]; + const navigationSpans = navigationSpanEnvelopeItem[0][1].items; + const navigationSpan = navigationSpans.find(s => getSpanOp(s) === 'navigation')!; + + expect(navigationSpanEnvelopeHeader).toEqual({ + sent_at: expect.any(String), + trace: { + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + }, + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + }); + + const numericSampleRand = parseFloat(navigationSpanEnvelopeHeader.trace!.sample_rand!); + expect(Number.isNaN(numericSampleRand)).toBe(false); + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(pageloadTraceId).not.toEqual(navigationTraceId); + + expect(pageloadSpan.name).toEqual('/index.html'); + + expect(navigationSpan).toEqual({ + attributes: { + effectiveConnectionType: { + type: 'string', + value: expect.any(String), + }, + hardwareConcurrency: { + type: 'string', + value: expect.any(String), + }, + 'sentry.idle_span_finish_reason': { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + 'sentry.previous_trace': { + type: 'string', + value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`, + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: SDK_VERSION, + }, + 'sentry.segment.id': { + type: 'string', + value: navigationSpan.span_id, + }, + 'sentry.segment.name': { + type: 'string', + value: '/index.html', + }, + 'sentry.source': { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'previous_trace', + }, + }, + sampled: true, + span_id: pageloadSpan.span_id, + trace_id: pageloadTraceId, + }, + ], + name: '/index.html', + span_id: navigationSpan.span_id, + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: navigationTraceId, + }); +}); + +sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpan1Promise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'navigation' && span.name === '/sub-page', + ); + const navigationSpan2Promise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'navigation' && span.name === '/sub-page-2', + ); + + await page.goto(url); + await pageloadSpanPromise; + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page`);"); + + const navigationSpan1 = await navigationSpan1Promise; + + expect(navigationSpan1.name).toEqual('/sub-page'); + + expect(navigationSpan1.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + }); + + await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page-2`);"); + + const navigationSpan2 = await navigationSpan2Promise; + + expect(navigationSpan2.name).toEqual('/sub-page-2'); + + expect(navigationSpan2.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.navigation.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'navigation', + }, + ['sentry.idle_span_finish_reason']: { + type: 'string', + value: 'idleTimeout', + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js new file mode 100644 index 000000000000..bd3b6ed17872 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts new file mode 100644 index 000000000000..47d9e00d4307 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -0,0 +1,131 @@ +import { expect } from '@playwright/test'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest( + 'creates a pageload streamed span envelope with url as pageload span name source', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'pageload'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spanEnvelope = await spanEnvelopePromise; + const envelopeHeader = spanEnvelope[0]; + const envelopeItem = spanEnvelope[1]; + const spans = envelopeItem[0][1].items; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload'); + + const timeOrigin = await page.evaluate('window._testBaseTimestamp'); + + expect(envelopeHeader).toEqual({ + sdk: { + name: 'sentry.javascript.browser', + version: SDK_VERSION, + }, + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rand: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + }); + + const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!); + const traceId = envelopeHeader.trace!.trace_id; + + expect(Number.isNaN(numericSampleRand)).toBe(false); + + expect(envelopeItem[0][0].item_count).toBeGreaterThan(1); + + expect(pageloadSpan?.start_timestamp).toBeCloseTo(timeOrigin, 1); + + expect(pageloadSpan).toEqual({ + attributes: { + effectiveConnectionType: { + type: 'string', + value: expect.any(String), + }, + hardwareConcurrency: { + type: 'string', + value: expect.any(String), + }, + 'performance.activationStart': { + type: 'integer', + value: expect.any(Number), + }, + 'performance.timeOrigin': { + type: 'double', + value: expect.any(Number), + }, + 'sentry.idle_span_finish_reason': { + type: 'string', + value: 'idleTimeout', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'pageload', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'auto.pageload.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + type: 'string', + value: 'sentry.javascript.browser', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + type: 'string', + value: SDK_VERSION, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + type: 'string', + value: pageloadSpan?.span_id, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + type: 'string', + value: '/index.html', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: '/index.html', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: traceId, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js new file mode 100644 index 000000000000..ded3ca204b6b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true }), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts new file mode 100644 index 000000000000..fb6fa3ab2393 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'waits for Sentry.reportPageLoaded() to be called when `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, + }); + + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2); + expect(spanDurationSeconds).toBeLessThan(3); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js new file mode 100644 index 000000000000..b1c19f779713 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableReportPageLoaded: true, finalTimeout: 3000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + debug: true, +}); + +// not calling Sentry.reportPageLoaded() on purpose! diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts new file mode 100644 index 000000000000..79df6a902e45 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'final timeout cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + 'sentry.idle_span_finish_reason': { type: 'string', value: 'finalTimeout' }, + }); + + // We wait for 3 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2.5); + expect(spanDurationSeconds).toBeLessThan(3.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js new file mode 100644 index 000000000000..ac42880742a3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ enableReportPageLoaded: true, instrumentNavigation: false }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.startBrowserTracingNavigationSpan(Sentry.getClient(), { name: 'custom_navigation' }); +}, 1000); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts new file mode 100644 index 000000000000..77f138f34053 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts @@ -0,0 +1,38 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; + +sentryTest( + 'starting a navigation span cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp; + + expect(pageloadSpan.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, + 'sentry.idle_span_finish_reason': { type: 'string', value: 'cancelled' }, + }); + + // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeLessThan(1.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js new file mode 100644 index 000000000000..510fb07540ad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js @@ -0,0 +1,28 @@ +// REGULAR --- +const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' }); +rootSpan1.end(); + +Sentry.startSpan({ name: 'rootSpan2' }, rootSpan2 => { + rootSpan2.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); +}); + +// NESTED --- +Sentry.startSpan({ name: 'rootSpan3' }, async rootSpan3 => { + Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => { + childSpan1.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan1.end(); + }); + + Sentry.startSpan({ name: 'childSpan3.2' }, async childSpan2 => { + childSpan2.addLink({ context: rootSpan3.spanContext() }); + + childSpan2.end(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts new file mode 100644 index 000000000000..dc35f0c8fcf1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts @@ -0,0 +1,66 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers'; +import { waitForStreamedSpan, waitForStreamedSpans } from '../../../utils/spanUtils'; + +sentryTest('links spans with addLink() in trace context', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment); + const rootSpan2Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan2' && !!s.is_segment); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = await rootSpan1Promise; + const rootSpan2 = await rootSpan2Promise; + + expect(rootSpan1.name).toBe('rootSpan1'); + expect(rootSpan1.links).toBeUndefined(); + + expect(rootSpan2.name).toBe('rootSpan2'); + expect(rootSpan2.links).toHaveLength(1); + expect(rootSpan2.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + sampled: true, + span_id: rootSpan1.span_id, + trace_id: rootSpan1.trace_id, + }); +}); + +sentryTest('links spans with addLink() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment); + const rootSpan3SpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'rootSpan3' && s.is_segment), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = await rootSpan1Promise; + const rootSpan3Spans = await rootSpan3SpansPromise; + + const rootSpan3 = rootSpan3Spans.find(s => s.name === 'rootSpan3')!; + const childSpan1 = rootSpan3Spans.find(s => s.name === 'childSpan3.1')!; + const childSpan2 = rootSpan3Spans.find(s => s.name === 'childSpan3.2')!; + + expect(rootSpan3.name).toBe('rootSpan3'); + + expect(childSpan1.name).toBe('childSpan3.1'); + expect(childSpan1.links).toHaveLength(1); + expect(childSpan1.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + sampled: true, + span_id: rootSpan1.span_id, + trace_id: rootSpan1.trace_id, + }); + + expect(childSpan2.name).toBe('childSpan3.2'); + expect(childSpan2.links?.[0]).toMatchObject({ + sampled: true, + span_id: rootSpan3.span_id, + trace_id: rootSpan3.trace_id, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js new file mode 100644 index 000000000000..c4c8791cf32c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js new file mode 100644 index 000000000000..482a738009c2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js @@ -0,0 +1,5 @@ +fetch('http://sentry-test-site.example/0').then( + fetch('http://sentry-test-site.example/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://sentry-test-site.example/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts new file mode 100644 index 000000000000..201c3e4979f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + url: { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'server.address': { type: 'string', value: 'sentry-test-site.example' }, + type: { type: 'string', value: 'fetch' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js new file mode 100644 index 000000000000..c4c8791cf32c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js new file mode 100644 index 000000000000..9c584bf743cb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js @@ -0,0 +1,12 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://sentry-test-site.example/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', 'http://sentry-test-site.example/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', 'http://sentry-test-site.example/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts new file mode 100644 index 000000000000..d3f20fd36453 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + name: `GET http://sentry-test-site.example/${index}`, + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + url: { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'server.address': { type: 'string', value: 'sentry-test-site.example' }, + type: { type: 'string', value: 'xhr' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js new file mode 100644 index 000000000000..0ce39588eb1b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js @@ -0,0 +1,14 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => { + Sentry.startSpan({ name: 'checkout-step-1-1' }, () => { + // ... ` + }); +}); + +Sentry.startSpan({ name: 'checkout-step-2' }, () => { + // ... ` +}); + +checkoutSpan.end(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts new file mode 100644 index 000000000000..a144e171a93a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('sets an inactive span active and adds child spans to it', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => s.name === 'checkout-flow' && s.is_segment)); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spans = await spansPromise; + const checkoutSpan = spans.find(s => s.name === 'checkout-flow'); + const checkoutSpanId = checkoutSpan?.span_id; + expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); + + expect(spans.filter(s => !s.is_segment)).toHaveLength(3); + + const checkoutStep1 = spans.find(s => s.name === 'checkout-step-1'); + const checkoutStep11 = spans.find(s => s.name === 'checkout-step-1-1'); + const checkoutStep2 = spans.find(s => s.name === 'checkout-step-2'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep11).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + + // despite 1-1 being called within 1, it's still parented to the root span + // due to this being default behaviour in browser environments + expect(checkoutStep11?.parent_span_id).toBe(checkoutSpanId); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js new file mode 100644 index 000000000000..5b4cff73e95d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js new file mode 100644 index 000000000000..dc601cbf4d30 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js @@ -0,0 +1,22 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); + +const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); +Sentry.setActiveSpanInBrowser(checkoutStep2); + +Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { + // ... ` +}); +checkoutStep2.end(); + +Sentry.startSpan({ name: 'checkout-step-3' }, () => {}); + +checkoutSpan.end(); + +Sentry.startSpan({ name: 'post-checkout' }, () => { + Sentry.startSpan({ name: 'post-checkout-1' }, () => { + // ... ` + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts new file mode 100644 index 000000000000..58728bba07f9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'nested calls to setActiveSpanInBrowser with parentSpanIsAlwaysRootSpan=false result in correct parenting', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const checkoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'checkout-flow' && s.is_segment), + ); + const postCheckoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'post-checkout' && s.is_segment), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutSpans = await checkoutSpansPromise; + const postCheckoutSpans = await postCheckoutSpansPromise; + + const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); + const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout'); + + const checkoutSpanId = checkoutSpan?.span_id; + const postCheckoutSpanId = postCheckoutSpan?.span_id; + + expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); + expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/); + + expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4); + expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1); + + const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1'); + const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2'); + const checkoutStep21 = checkoutSpans.find(s => s.name === 'checkout-step-2-1'); + const checkoutStep3 = checkoutSpans.find(s => s.name === 'checkout-step-3'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + expect(checkoutStep21).toBeDefined(); + expect(checkoutStep3).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + + // with parentSpanIsAlwaysRootSpan=false, 2-1 is parented to 2 because + // 2 was the active span when 2-1 was started + expect(checkoutStep21?.parent_span_id).toBe(checkoutStep2?.span_id); + + // since the parent of three is `checkoutSpan`, we correctly reset + // the active span to `checkoutSpan` after 2 ended + expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); + + // post-checkout trace is started as a new trace because ending checkoutSpan removes the active + // span on the scope + const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1'); + expect(postCheckoutStep1).toBeDefined(); + expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js new file mode 100644 index 000000000000..9afcee48dc4a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js new file mode 100644 index 000000000000..dc601cbf4d30 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js @@ -0,0 +1,22 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setActiveSpanInBrowser(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); + +const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); +Sentry.setActiveSpanInBrowser(checkoutStep2); + +Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { + // ... ` +}); +checkoutStep2.end(); + +Sentry.startSpan({ name: 'checkout-step-3' }, () => {}); + +checkoutSpan.end(); + +Sentry.startSpan({ name: 'post-checkout' }, () => { + Sentry.startSpan({ name: 'post-checkout-1' }, () => { + // ... ` + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts new file mode 100644 index 000000000000..4d11a36982b7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'nested calls to setActiveSpanInBrowser still parent to root span by default', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const checkoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'checkout-flow' && s.is_segment), + ); + const postCheckoutSpansPromise = waitForStreamedSpans(page, spans => + spans.some(s => s.name === 'post-checkout' && s.is_segment), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutSpans = await checkoutSpansPromise; + const postCheckoutSpans = await postCheckoutSpansPromise; + + const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); + const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout'); + + const checkoutSpanId = checkoutSpan?.span_id; + const postCheckoutSpanId = postCheckoutSpan?.span_id; + + expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); + expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/); + + expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4); + expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1); + + const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1'); + const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2'); + const checkoutStep21 = checkoutSpans.find(s => s.name === 'checkout-step-2-1'); + const checkoutStep3 = checkoutSpans.find(s => s.name === 'checkout-step-3'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + expect(checkoutStep21).toBeDefined(); + expect(checkoutStep3).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); + + // despite 2-1 being called within 2 AND setting 2 as active span, it's still parented to the + // root span due to this being default behaviour in browser environments + expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId); + + const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1'); + expect(postCheckoutStep1).toBeDefined(); + expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js new file mode 100644 index 000000000000..3dd77207e103 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; +// Import this separately so that generatePlugin can handle it for CDN scenarios +import { feedbackIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts new file mode 100644 index 000000000000..28f3e5039910 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts @@ -0,0 +1,318 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipFeedbackTest, + shouldSkipTracingTest, + testingCdnBundle, +} from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest('creates a new trace and sample_rand on each navigation', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Wait for and skip the initial pageload span + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigation1SpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#foo`); + const navigation1SpanEnvelope = await navigation1SpanEnvelopePromise; + + const navigation2SpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#bar`); + const navigation2SpanEnvelope = await navigation2SpanEnvelopePromise; + + const navigation1TraceId = navigation1SpanEnvelope[0].trace?.trace_id; + const navigation1SampleRand = navigation1SpanEnvelope[0].trace?.sample_rand; + const navigation2TraceId = navigation2SpanEnvelope[0].trace?.trace_id; + const navigation2SampleRand = navigation2SpanEnvelope[0].trace?.sample_rand; + + const navigation1Span = navigation1SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + const navigation2Span = navigation2SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!; + + expect(getSpanOp(navigation1Span)).toEqual('navigation'); + expect(navigation1TraceId).toMatch(/^[\da-f]{32}$/); + expect(navigation1Span.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigation1Span.parent_span_id).toBeUndefined(); + + expect(navigation1SpanEnvelope[0].trace).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigation1TraceId, + sample_rand: expect.any(String), + }); + + expect(getSpanOp(navigation2Span)).toEqual('navigation'); + expect(navigation2TraceId).toMatch(/^[\da-f]{32}$/); + expect(navigation2Span.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigation2Span.parent_span_id).toBeUndefined(); + + expect(navigation2SpanEnvelope[0].trace).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigation2TraceId, + sample_rand: expect.any(String), + }); + + expect(navigation1TraceId).not.toEqual(navigation2TraceId); + expect(navigation1SampleRand).not.toEqual(navigation2SampleRand); +}); + +sentryTest('error after navigation has navigation traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + await page.goto(`${url}#foo`); + const [navigationSpan, navigationSpanEnvelope] = await Promise.all([ + navigationSpanPromise, + navigationSpanEnvelopePromise, + ]); + + const navigationTraceId = navigationSpan.trace_id; + + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + expect(navigationSpanEnvelope[0].trace).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigationTraceId, + sample_rand: expect.any(String), + }); + + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + await page.locator('#errorBtn').click(); + const [errorEvent, errorTraceHeader] = await errorEventPromise; + + expect(errorEvent.type).toEqual(undefined); + + const errorTraceContext = errorEvent.contexts?.trace; + expect(errorTraceContext).toEqual({ + trace_id: navigationTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigationTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest('error during navigation has new navigation traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + + await page.goto(`${url}#foo`); + await page.locator('#errorBtn').click(); + const [navigationSpan, [errorEvent, errorTraceHeader]] = await Promise.all([ + navigationSpanPromise, + errorEventPromise, + ]); + + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(errorEvent.type).toEqual(undefined); + + const navigationTraceId = navigationSpan.trace_id; + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + const errorTraceContext = errorEvent?.contexts?.trace; + expect(errorTraceContext).toEqual({ + trace_id: navigationTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigationTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest( + 'outgoing fetch request during navigation has navigation traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(`${url}#foo`); + await page.locator('#fetchBtn').click(); + const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]); + + const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id; + const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand; + + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toEqual( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest( + 'outgoing XHR request during navigation has navigation traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // ensure navigation span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(`${url}#foo`); + await page.locator('#xhrBtn').click(); + const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]); + + const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id; + const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand; + + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toEqual( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest( + 'user feedback event after navigation has navigation traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true }); + + // ensure pageload span is finished + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + await page.goto(`${url}#foo`); + const navigationSpan = await navigationSpanPromise; + + const navigationTraceId = navigationSpan.trace_id; + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(navigationTraceId).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + const feedbackEventPromise = getFirstSentryEnvelopeRequest(page); + + await page.getByText('Report a Bug').click(); + expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1); + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('my example feedback'); + await page.locator('[data-sentry-feedback] .btn--primary').click(); + + const feedbackEvent = await feedbackEventPromise; + + expect(feedbackEvent.type).toEqual('feedback'); + + const feedbackTraceContext = feedbackEvent.contexts?.trace; + + expect(feedbackTraceContext).toMatchObject({ + trace_id: navigationTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js new file mode 100644 index 000000000000..3dd77207e103 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; +// Import this separately so that generatePlugin can handle it for CDN scenarios +import { feedbackIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts new file mode 100644 index 000000000000..1b4458991559 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts @@ -0,0 +1,238 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipFeedbackTest, + shouldSkipTracingTest, + testingCdnBundle, +} from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest('creates a new trace for a navigation after the initial pageload', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + + page.goto(`${url}#foo`); + + const navigationSpan = await navigationSpanPromise; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + expect(getSpanOp(navigationSpan)).toEqual('navigation'); + expect(navigationSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(navigationSpan.parent_span_id).toBeUndefined(); + + expect(pageloadSpan.span_id).not.toEqual(navigationSpan.span_id); + expect(pageloadSpan.trace_id).not.toEqual(navigationSpan.trace_id); +}); + +sentryTest('error after pageload has pageload traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = await pageloadSpanPromise; + const pageloadTraceId = pageloadSpan.trace_id; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + await page.locator('#errorBtn').click(); + const [errorEvent, errorTraceHeader] = await errorEventPromise; + + const errorTraceContext = errorEvent.contexts?.trace; + expect(errorEvent.type).toEqual(undefined); + + expect(errorTraceContext).toEqual({ + trace_id: pageloadTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: pageloadTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest('error during pageload has pageload traceId', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + + await page.goto(url); + await page.locator('#errorBtn').click(); + const [pageloadSpan, [errorEvent, errorTraceHeader]] = await Promise.all([pageloadSpanPromise, errorEventPromise]); + + const pageloadTraceId = pageloadSpan.trace_id; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + const errorTraceContext = errorEvent?.contexts?.trace; + expect(errorEvent.type).toEqual(undefined); + + expect(errorTraceContext).toEqual({ + trace_id: pageloadTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(errorTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: pageloadTraceId, + sample_rand: expect.any(String), + }); +}); + +sentryTest( + 'outgoing fetch request during pageload has pageload traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(url); + await page.locator('#fetchBtn').click(); + const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]); + + const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id; + const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand; + + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toBe( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest( + 'outgoing XHR request during pageload has pageload traceId in headers', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'), + ); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + await page.goto(url); + await page.locator('#xhrBtn').click(); + const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]); + + const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id; + const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand; + + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + + const headers = request.headers(); + + // sampling decision is propagated from active span sampling decision + expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`)); + expect(headers['baggage']).toBe( + `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`, + ); + }, +); + +sentryTest('user feedback event after pageload has pageload traceId in headers', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageloadSpan = await pageloadSpanPromise; + const pageloadTraceId = pageloadSpan.trace_id; + + expect(getSpanOp(pageloadSpan)).toEqual('pageload'); + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + + const feedbackEventPromise = getFirstSentryEnvelopeRequest(page); + + await page.getByText('Report a Bug').click(); + expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1); + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('my example feedback'); + await page.locator('[data-sentry-feedback] .btn--primary').click(); + + const feedbackEvent = await feedbackEventPromise; + + expect(feedbackEvent.type).toEqual('feedback'); + + const feedbackTraceContext = feedbackEvent.contexts?.trace; + + expect(feedbackTraceContext).toMatchObject({ + trace_id: pageloadTraceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js new file mode 100644 index 000000000000..187e07624fdf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js new file mode 100644 index 000000000000..3bb1e489ccb6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js @@ -0,0 +1,15 @@ +const newTraceBtn = document.getElementById('newTrace'); +newTraceBtn.addEventListener('click', async () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ op: 'ui.interaction.click', name: 'new-trace' }, async () => { + await fetch('http://sentry-test-site.example'); + }); + }); +}); + +const oldTraceBtn = document.getElementById('oldTrace'); +oldTraceBtn.addEventListener('click', async () => { + Sentry.startSpan({ op: 'ui.interaction.click', name: 'old-trace' }, async () => { + await fetch('http://sentry-test-site.example'); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html new file mode 100644 index 000000000000..f78960343dd0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts new file mode 100644 index 000000000000..d294efcd2e3b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest( + 'creates a new trace if `startNewTrace` is called and leaves old trace valid outside the callback', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + await page.goto(url); + const pageloadSpan = await pageloadSpanPromise; + + const newTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'new-trace'); + const oldTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'old-trace'); + + await page.locator('#newTrace').click(); + await page.locator('#oldTrace').click(); + + const [newTraceSpan, oldTraceSpan] = await Promise.all([newTraceSpanPromise, oldTraceSpanPromise]); + + expect(getSpanOp(newTraceSpan)).toEqual('ui.interaction.click'); + expect(newTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(newTraceSpan.span_id).toMatch(/^[\da-f]{16}$/); + + expect(getSpanOp(oldTraceSpan)).toEqual('ui.interaction.click'); + expect(oldTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/); + expect(oldTraceSpan.span_id).toMatch(/^[\da-f]{16}$/); + + expect(oldTraceSpan.trace_id).toEqual(pageloadSpan.trace_id); + expect(newTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id); + }, +); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 50150c6bee20..5dade230e1e4 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -62,7 +62,7 @@ export const eventAndTraceHeaderRequestParser = (request: Request | null): Event return getEventAndTraceHeader(envelope); }; -const properFullEnvelopeParser = (request: Request | null): T => { +export const properFullEnvelopeParser = (request: Request | null): T => { // https://develop.sentry.dev/sdk/envelopes/ const envelope = request?.postData() || ''; diff --git a/dev-packages/browser-integration-tests/utils/spanUtils.ts b/dev-packages/browser-integration-tests/utils/spanUtils.ts new file mode 100644 index 000000000000..67b5798b66f1 --- /dev/null +++ b/dev-packages/browser-integration-tests/utils/spanUtils.ts @@ -0,0 +1,133 @@ +import type { Page } from '@playwright/test'; +import type { SerializedStreamedSpan, StreamedSpanEnvelope } from '@sentry/core'; +import { properFullEnvelopeParser } from './helpers'; + +/** + * Wait for a full span v2 envelope + * Useful for testing the entire envelope shape + */ +export async function waitForStreamedSpanEnvelope( + page: Page, + callback?: (spanEnvelope: StreamedSpanEnvelope) => boolean, +): Promise { + const req = await page.waitForRequest(req => { + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + const spanEnvelope = properFullEnvelopeParser(req); + + const envelopeItemHeader = spanEnvelope[1][0][0]; + + if ( + envelopeItemHeader?.type !== 'span' || + envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json' + ) { + return false; + } + + if (callback) { + return callback(spanEnvelope); + } + + return true; + } catch { + return false; + } + }); + + return properFullEnvelopeParser(req); +} + +/** + * Wait for v2 spans sent in one envelope. + * Useful for testing multiple spans in one envelope. + * @param page + * @param callback - Callback being called with all spans + */ +export async function waitForStreamedSpans( + page: Page, + callback?: (spans: SerializedStreamedSpan[]) => boolean, +): Promise { + const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => { + if (callback) { + return callback(envelope[1][0][1].items); + } + return true; + }); + return spanEnvelope[1][0][1].items; +} + +export async function waitForStreamedSpan( + page: Page, + callback: (span: SerializedStreamedSpan) => boolean, +): Promise { + const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => { + if (callback) { + const spans = envelope[1][0][1].items; + return spans.some(span => callback(span)); + } + return true; + }); + const firstMatchingSpan = spanEnvelope[1][0][1].items.find(span => callback(span)); + if (!firstMatchingSpan) { + throw new Error( + 'No matching span found but envelope search matched previously. Something is likely off with this function. Debug me.', + ); + } + return firstMatchingSpan; +} + +/** + * Observes outgoing requests and looks for sentry envelope requests. If an envelope request is found, it applies + * @param callback to check for a matching span. + * + * Important: This function only observes requests and does not block the test when it ends. Use this primarily to + * throw errors if you encounter unwanted spans. You most likely want to use {@link waitForStreamedSpan} or {@link waitForStreamedSpans} instead! + */ +export async function observeStreamedSpan( + page: Page, + callback: (span: SerializedStreamedSpan) => boolean, +): Promise { + page.on('request', request => { + const postData = request.postData(); + if (!postData) { + return; + } + + try { + const spanEnvelope = properFullEnvelopeParser(request); + + const envelopeItemHeader = spanEnvelope[1][0][0]; + + if ( + envelopeItemHeader?.type !== 'span' || + envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json' + ) { + return false; + } + + const spans = spanEnvelope[1][0][1].items; + + for (const span of spans) { + if (callback(span)) { + return true; + } + } + + return false; + } catch { + return false; + } + }); +} + +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { + return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes?.['sentry.op']?.value : undefined; +} + +export function getSpansFromEnvelope(envelope: StreamedSpanEnvelope): SerializedStreamedSpan[] { + return envelope[1][0][1].items; +} diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index e8e3f8bcd542..f741e2746885 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -6,6 +6,7 @@ import { hasSpanStreamingEnabled, isStreamedBeforeSendSpanCallback, SpanBuffer, + spanIsSampled, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -44,7 +45,15 @@ export const spanStreamingIntegration = defineIntegration(() => { const buffer = new SpanBuffer(client); - client.on('afterSpanEnd', span => buffer.add(captureSpan(span, client))); + client.on('afterSpanEnd', span => { + // Negatively sampled spans must not be captured. + // This happens because OTel and we create non-recording spans for negatively sampled spans + // that go through the same life cycle as recording spans. + if (!spanIsSampled(span)) { + return; + } + buffer.add(captureSpan(span, client)); + }); // In addition to capturing the span, we also flush the trace when the segment // span ends to ensure things are sent timely. We never know when the browser diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index 6993e494f9ce..63e07738570c 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -1,6 +1,6 @@ import * as SentryCore from '@sentry/core'; import { debug } from '@sentry/core'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient, spanStreamingIntegration } from '../../src'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; @@ -24,6 +24,10 @@ vi.mock('@sentry/core', async () => { }); describe('spanStreamingIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('has the correct hooks', () => { const integration = spanStreamingIntegration(); expect(integration.name).toBe('SpanStreaming'); @@ -106,12 +110,13 @@ describe('spanStreamingIntegration', () => { ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], + tracesSampleRate: 1, }); SentryCore.setCurrentClient(client); client.init(); - const span = new SentryCore.SentrySpan({ name: 'test' }); + const span = new SentryCore.SentrySpan({ name: 'test', sampled: true }); client.emit('afterSpanEnd', span); expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({ @@ -148,6 +153,24 @@ describe('spanStreamingIntegration', () => { }); }); + it('does not enqueue a span into the buffer when the span is not sampled', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: false }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled(); + }); + it('flushes the trace when the segment span ends', () => { const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5d926f51769..a2af055232a1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -467,6 +467,7 @@ export type { SpanJSON, SpanContextData, TraceFlag, + SerializedStreamedSpan, StreamedSpanJSON, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 2168530a9c91..d22905670efc 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -332,7 +332,12 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default). */ export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { - return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error'; + return !status || + status.code === SPAN_STATUS_OK || + status.code === SPAN_STATUS_UNSET || + status.message === 'cancelled' + ? 'ok' + : 'error'; } const CHILD_SPANS_FIELD = '_sentryChildSpans'; From 1956c5b7275da413e3cdeb02ea39a5a2cf1afda0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 9 Mar 2026 14:26:45 +0100 Subject: [PATCH 10/12] fix(browser): Apply Http timing attributes to streamed `http.client` spans (#19643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes a temporary bug in span streaming where we didn't add Http timing attributes (see https://github.com/getsentry/sentry-javascript/issues/19613). We can fix this by following OTels approach: - delay the ending of `http.client` spans until either 300ms pass by or we receive the PerformanceResourceTiming entry with the respective timing information. Of course we end the span with the original timestamp then. - Unfortunately, we can only do this for streamed span because transaction-based spans cannot stay open longer than their parent (e.g. a pageload or navigation). Otherwise they'd get dropped. So we have to differentiate between the two modes here (RIP bundle size 😢) - To ensure we don't flush unnecessarily often, we also now delay flushing the span buffer for 500ms after a segment span ends. This slightly changed test semantics in a few integration tests because manually consecutively segments are now also sent in one envelope. This is completely fine (actually preferred) because we flush less often (i.e. fewer requests). closes https://github.com/getsentry/sentry-javascript/issues/19613 --- .size-limit.js | 2 +- .../http-timings-streamed/test.ts | 12 +--- .../nested-parentAlwaysRoot/test.ts | 11 +--- .../setSpanActive-streamed/nested/test.ts | 11 +--- .../browser/src/integrations/spanstreaming.ts | 7 ++- packages/browser/src/tracing/request.ts | 56 ++++++++++++++++--- .../test/integrations/spanstreaming.test.ts | 7 ++- 7 files changed, 70 insertions(+), 36 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 44e701b3466b..3a4689d59faa 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -241,7 +241,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '129 KB', + limit: '130 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts index ffa63937bf32..25d4ac497992 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts @@ -3,16 +3,8 @@ import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; -/** - * This test details a limitation of span streaming in comparison to transaction-based tracing: - * We can no longer attach http PerformanceResourceTiming attributes to http.client spans in - * span streaming mode. The reason is that we track `http.client` spans in real time but only - * get the detailed timing information after the span already ended. - * We can probably fix this (somehat at least) but will do so in a follow-up PR. - * @see https://github.com/getsentry/sentry-javascript/issues/19613 - */ sentryTest( - "[limitation] doesn't add http timing to http.client spans in span streaming mode", + 'adds http timing to http.client spans in span streaming mode', async ({ browserName, getLocalTestUrl, page }) => { const supportedBrowsers = ['chromium', 'firefox']; @@ -49,7 +41,7 @@ sentryTest( end_timestamp: expect.any(Number), trace_id: pageloadSpan?.trace_id, status: 'ok', - attributes: expect.not.objectContaining({ + attributes: expect.objectContaining({ 'http.request.redirect_start': expect.any(Object), 'http.request.redirect_end': expect.any(Object), 'http.request.worker_start': expect.any(Object), diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts index 58728bba07f9..8f5e54e1fba0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts @@ -11,18 +11,14 @@ sentryTest( const checkoutSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => s.name === 'checkout-flow' && s.is_segment), ); - const postCheckoutSpansPromise = waitForStreamedSpans(page, spans => - spans.some(s => s.name === 'post-checkout' && s.is_segment), - ); const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); const checkoutSpans = await checkoutSpansPromise; - const postCheckoutSpans = await postCheckoutSpansPromise; const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); - const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout'); + const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout'); const checkoutSpanId = checkoutSpan?.span_id; const postCheckoutSpanId = postCheckoutSpan?.span_id; @@ -30,8 +26,7 @@ sentryTest( expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/); - expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4); - expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1); + expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5); const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1'); const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2'); @@ -56,7 +51,7 @@ sentryTest( // post-checkout trace is started as a new trace because ending checkoutSpan removes the active // span on the scope - const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1'); + const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1'); expect(postCheckoutStep1).toBeDefined(); expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts index 4d11a36982b7..1b04553090bc 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts @@ -11,18 +11,14 @@ sentryTest( const checkoutSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => s.name === 'checkout-flow' && s.is_segment), ); - const postCheckoutSpansPromise = waitForStreamedSpans(page, spans => - spans.some(s => s.name === 'post-checkout' && s.is_segment), - ); const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); const checkoutSpans = await checkoutSpansPromise; - const postCheckoutSpans = await postCheckoutSpansPromise; const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow'); - const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout'); + const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout'); const checkoutSpanId = checkoutSpan?.span_id; const postCheckoutSpanId = postCheckoutSpan?.span_id; @@ -30,8 +26,7 @@ sentryTest( expect(checkoutSpanId).toMatch(/[a-f\d]{16}/); expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/); - expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4); - expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1); + expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5); const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1'); const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2'); @@ -51,7 +46,7 @@ sentryTest( // root span due to this being default behaviour in browser environments expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId); - const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1'); + const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1'); expect(postCheckoutStep1).toBeDefined(); expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); }, diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index f741e2746885..ab07f75c2b7d 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -58,7 +58,12 @@ export const spanStreamingIntegration = defineIntegration(() => { // In addition to capturing the span, we also flush the trace when the segment // span ends to ensure things are sent timely. We never know when the browser // is closed, users navigate away, etc. - client.on('afterSegmentSpanEnd', segmentSpan => buffer.flush(segmentSpan.spanContext().traceId)); + client.on('afterSegmentSpanEnd', segmentSpan => { + const traceId = segmentSpan.spanContext().traceId; + setTimeout(() => { + buffer.flush(traceId); + }, 500); + }); }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 85faf0871a55..6211cf72947a 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { Client, HandlerDataXhr, @@ -5,6 +6,7 @@ import type { ResponseHookInfo, SentryWrappedXMLHttpRequest, Span, + SpanTimeInput, } from '@sentry/core'; import { addFetchEndInstrumentationHandler, @@ -14,6 +16,7 @@ import { getLocationHref, getTraceData, hasSpansEnabled, + hasSpanStreamingEnabled, instrumentFetchRequest, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -25,6 +28,7 @@ import { stringMatchesSomePattern, stripDataUrlContent, stripUrlQueryAndFragment, + timestampInSeconds, } from '@sentry/core'; import type { XhrHint } from '@sentry-internal/browser-utils'; import { @@ -205,7 +209,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { + // Clean up the performance observer and other resources + // We have to wait here because otherwise this cleans itself up before it is fully done. + // Default (non-streaming): just deregister the observer. + let onEntryFound = (): void => void setTimeout(unsubscribePerformanceObsever); + + // For streamed spans, we have to artificially delay the ending of the span until we + // either receive the timing data, or HTTP_TIMING_WAIT_MS elapses. + if (hasSpanStreamingEnabled(client)) { + const originalEnd = span.end.bind(span); + + span.end = (endTimestamp?: SpanTimeInput) => { + const capturedEndTimestamp = endTimestamp ?? timestampInSeconds(); + let isEnded = false; + + const endSpanAndCleanup = (): void => { + if (isEnded) { + return; + } + isEnded = true; + setTimeout(unsubscribePerformanceObsever); + originalEnd(capturedEndTimestamp); + clearTimeout(fallbackTimeout); + }; + + onEntryFound = endSpanAndCleanup; + + // Fallback: always end the span after HTTP_TIMING_WAIT_MS even if no + // PerformanceResourceTiming entry arrives (e.g. cross-origin without + // Timing-Allow-Origin, or the browser didn't fire the observer in time). + const fallbackTimeout = setTimeout(endSpanAndCleanup, HTTP_TIMING_WAIT_MS); + }; + } + + const unsubscribePerformanceObsever = addPerformanceInstrumentationHandler('resource', ({ entries }) => { entries.forEach(entry => { if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) { span.setAttributes(resourceTimingToSpanAttributes(entry)); - // In the next tick, clean this handler up - // We have to wait here because otherwise this cleans itself up before it is fully done - setTimeout(cleanup); + onEntryFound(); } }); }); diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index 63e07738570c..5b84c52f8d7e 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -171,7 +171,8 @@ describe('spanStreamingIntegration', () => { expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled(); }); - it('flushes the trace when the segment span ends', () => { + it('flushes the trace when the segment span ends after a delay for close to finished child spans', () => { + vi.useFakeTimers(); const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', @@ -185,6 +186,10 @@ describe('spanStreamingIntegration', () => { const span = new SentryCore.SentrySpan({ name: 'test' }); client.emit('afterSegmentSpanEnd', span); + vi.advanceTimersByTime(500); + expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId); + + vi.useRealTimers(); }); }); From 78cf6286c10bd2fff2d3e0cb8d57c0e3fb825d0d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 9 Mar 2026 14:43:17 +0100 Subject: [PATCH 11/12] fix(core): Replace global interval with trace-specific interval based flushing (#19686) As discussed yesterday with @cleptric, we don't want a global interval-based flushing but the interval shall be set per trace bucket. This PR makes that change. --- packages/core/src/tracing/spans/spanBuffer.ts | 93 +++++++++---------- .../test/lib/tracing/spans/spanBuffer.test.ts | 4 +- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/packages/core/src/tracing/spans/spanBuffer.ts b/packages/core/src/tracing/spans/spanBuffer.ts index d6451acb5e44..cd011df5ac49 100644 --- a/packages/core/src/tracing/spans/spanBuffer.ts +++ b/packages/core/src/tracing/spans/spanBuffer.ts @@ -16,6 +16,12 @@ const MAX_SPANS_PER_ENVELOPE = 1000; const MAX_TRACE_WEIGHT_IN_BYTES = 5_000_000; +interface TraceBucket { + spans: Set; + size: number; + timeout: ReturnType; +} + export interface SpanBufferOptions { /** * Max spans per trace before auto-flush @@ -26,7 +32,8 @@ export interface SpanBufferOptions { maxSpanLimit?: number; /** - * Flush interval in ms + * Per-trace flush timeout in ms. A timeout is started when a trace bucket is first created + * and fires flush() for that specific trace when it expires. * Must be greater than 0. * * @default 5_000 @@ -44,7 +51,7 @@ export interface SpanBufferOptions { /** * A buffer for serialized streamed span JSON objects that flushes them to Sentry in Span v2 envelopes. - * Handles interval-based flushing, size thresholds, and graceful shutdown. + * Handles per-trace timeout-based flushing, size thresholds, and graceful shutdown. * Also handles computation of the Dynamic Sampling Context (DSC) for the trace, if it wasn't yet * frozen onto the segment span. * @@ -54,19 +61,16 @@ export interface SpanBufferOptions { * still active and modifyable when child spans are added to the buffer. */ export class SpanBuffer { - /* Bucket spans by their trace id */ - private _traceMap: Map>; - private _traceWeightMap: Map; + /* Bucket spans by their trace id, along with accumulated size and a per-trace flush timeout */ + private _traceBuckets: Map; - private _flushIntervalId: ReturnType | null; private _client: Client; private _maxSpanLimit: number; private _flushInterval: number; private _maxTraceWeight: number; public constructor(client: Client, options?: SpanBufferOptions) { - this._traceMap = new Map(); - this._traceWeightMap = new Map(); + this._traceBuckets = new Map(); this._client = client; const { maxSpanLimit, flushInterval, maxTraceWeightInBytes } = options ?? {}; @@ -79,9 +83,6 @@ export class SpanBuffer { this._maxTraceWeight = maxTraceWeightInBytes && maxTraceWeightInBytes > 0 ? maxTraceWeightInBytes : MAX_TRACE_WEIGHT_IN_BYTES; - this._flushIntervalId = null; - this._debounceFlushInterval(); - this._client.on('flush', () => { this.drain(); }); @@ -89,11 +90,10 @@ export class SpanBuffer { this._client.on('close', () => { // No need to drain the buffer here as `Client.close()` internally already calls `Client.flush()` // which already invokes the `flush` hook and thus drains the buffer. - if (this._flushIntervalId) { - clearInterval(this._flushIntervalId); - } - this._traceMap.clear(); - this._traceWeightMap.clear(); + this._traceBuckets.forEach(bucket => { + clearTimeout(bucket.timeout); + }); + this._traceBuckets.clear(); }); } @@ -102,20 +102,26 @@ export class SpanBuffer { */ public add(spanJSON: SerializedStreamedSpanWithSegmentSpan): void { const traceId = spanJSON.trace_id; - let traceBucket = this._traceMap.get(traceId); - if (traceBucket) { - traceBucket.add(spanJSON); - } else { - traceBucket = new Set([spanJSON]); - this._traceMap.set(traceId, traceBucket); + let bucket = this._traceBuckets.get(traceId); + + if (!bucket) { + bucket = { + spans: new Set(), + size: 0, + timeout: safeUnref( + setTimeout(() => { + this.flush(traceId); + }, this._flushInterval), + ), + }; + this._traceBuckets.set(traceId, bucket); } - const newWeight = (this._traceWeightMap.get(traceId) ?? 0) + estimateSerializedSpanSizeInBytes(spanJSON); - this._traceWeightMap.set(traceId, newWeight); + bucket.spans.add(spanJSON); + bucket.size += estimateSerializedSpanSizeInBytes(spanJSON); - if (traceBucket.size >= this._maxSpanLimit || newWeight >= this._maxTraceWeight) { + if (bucket.spans.size >= this._maxSpanLimit || bucket.size >= this._maxTraceWeight) { this.flush(traceId); - this._debounceFlushInterval(); } } @@ -123,36 +129,35 @@ export class SpanBuffer { * Drain and flush all buffered traces. */ public drain(): void { - if (!this._traceMap.size) { + if (!this._traceBuckets.size) { return; } - DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._traceMap.size} traces`); + DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._traceBuckets.size} traces`); - this._traceMap.forEach((_, traceId) => { + this._traceBuckets.forEach((_, traceId) => { this.flush(traceId); }); - this._debounceFlushInterval(); } /** * Flush spans of a specific trace. - * In contrast to {@link SpanBuffer.flush}, this method does not flush all traces, but only the one with the given traceId. + * In contrast to {@link SpanBuffer.drain}, this method does not flush all traces, but only the one with the given traceId. */ public flush(traceId: string): void { - const traceBucket = this._traceMap.get(traceId); - if (!traceBucket) { + const bucket = this._traceBuckets.get(traceId); + if (!bucket) { return; } - if (!traceBucket.size) { - // we should never get here, given we always add a span when we create a new bucket + if (!bucket.spans.size) { + // we should never get here, given we always add a span when we create a new bucket // and delete the bucket once we flush out the trace this._removeTrace(traceId); return; } - const spans = Array.from(traceBucket); + const spans = Array.from(bucket.spans); const segmentSpan = spans[0]?._segmentSpan; if (!segmentSpan) { @@ -181,18 +186,10 @@ export class SpanBuffer { } private _removeTrace(traceId: string): void { - this._traceMap.delete(traceId); - this._traceWeightMap.delete(traceId); - } - - private _debounceFlushInterval(): void { - if (this._flushIntervalId) { - clearInterval(this._flushIntervalId); + const bucket = this._traceBuckets.get(traceId); + if (bucket) { + clearTimeout(bucket.timeout); } - this._flushIntervalId = safeUnref( - setInterval(() => { - this.drain(); - }, this._flushInterval), - ); + this._traceBuckets.delete(traceId); } } diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts index 44a6f6f954db..cbcb1bf7ea59 100644 --- a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts +++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts @@ -70,7 +70,7 @@ describe('SpanBuffer', () => { expect(sentEnvelopes[1]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace456'); }); - it('drains on interval', () => { + it('flushes trace after per-trace timeout', () => { const buffer = new SpanBuffer(client, { flushInterval: 1000 }); const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true }); @@ -106,7 +106,7 @@ describe('SpanBuffer', () => { expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); - // since the buffer is now empty, it should not send anything anymore + // the trace bucket was removed after flushing, so no timeout remains and no further sends occur vi.advanceTimersByTime(1000); expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); }); From 8c821cb7e6631d298faced4820fbfa42a9d6adef Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 12 Mar 2026 17:16:26 +0100 Subject: [PATCH 12/12] feat(node-core): Add POtel server-side span streaming implementation (#19741) This PR adds a server-side span streaming implementation, for now scoped to POtel SDKs. However we can reuse some stuff from this PR to very easily enable span streaming on Cloudflare, Vercel Edge and other OTel-less platforms. Main changes: - added `spanStreamingIntegration` to `@sentry/core`: This orchestrates the span streaming life cycle via the client and the span buffer. It's very similar to the already existing `spanStreamingIntegration` in browser but doesn't expose some of the behaviour that we need only in browser. - adjusted `SentrySpanProcessor` to emit the right client hooks instead of passing the span to the `SpanExporter`. - adjusted the SDKs' default integrations to include `spanStreamingIntegration` when users set `traceLifecycle: 'stream'` in their SDK init. Rest are tests and small refactors. I'll follow up with Node integration tests once this is merged to avoid bloating this PR further. ref #17836 --- .size-limit.js | 14 +- packages/astro/src/index.server.ts | 1 + packages/astro/src/index.types.ts | 1 + packages/aws-serverless/src/index.ts | 1 + .../browser/src/integrations/spanstreaming.ts | 6 +- .../test/integrations/spanstreaming.test.ts | 42 +++--- packages/bun/src/index.ts | 1 + packages/core/src/index.ts | 1 + .../core/src/integrations/spanStreaming.ts | 44 ++++++ .../test/integrations/spanStreaming.test.ts | 139 ++++++++++++++++++ packages/google-cloud-serverless/src/index.ts | 1 + packages/nextjs/src/index.types.ts | 1 + packages/node-core/src/common-exports.ts | 1 + packages/node-core/src/light/sdk.ts | 15 +- packages/node-core/src/sdk/index.ts | 15 +- packages/node-core/test/light/sdk.test.ts | 33 +++++ packages/node-core/test/sdk/init.test.ts | 33 +++++ packages/node/src/index.ts | 1 + packages/node/test/sdk/init.test.ts | 33 +++++ packages/nuxt/src/index.types.ts | 1 + packages/opentelemetry/src/spanProcessor.ts | 90 ++++++------ .../opentelemetry/test/spanProcessor.test.ts | 57 +++++++ packages/react-router/src/index.types.ts | 1 + packages/remix/src/index.types.ts | 1 + packages/remix/src/server/index.ts | 1 + packages/solidstart/src/index.types.ts | 1 + packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/index.types.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + .../tanstackstart-react/src/index.types.ts | 1 + 30 files changed, 456 insertions(+), 83 deletions(-) create mode 100644 packages/core/src/integrations/spanStreaming.ts create mode 100644 packages/core/test/integrations/spanStreaming.test.ts create mode 100644 packages/opentelemetry/test/spanProcessor.test.ts diff --git a/.size-limit.js b/.size-limit.js index 3a4689d59faa..aef521dc83f6 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -117,7 +117,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics'), gzip: true, - limit: '27 KB', + limit: '28 KB', }, { name: '@sentry/browser (incl. Logs)', @@ -220,13 +220,13 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '86 KB', + limit: '87 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '87 KB', + limit: '88 KB', }, // browser CDN bundles (non-gzipped) { @@ -317,7 +317,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '53 KB', + limit: '55 KB', }, // Node SDK (ESM) { @@ -326,14 +326,14 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '175 KB', + limit: '177 KB', }, { name: '@sentry/node - without tracing', path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '98 KB', + limit: '100 KB', ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -356,7 +356,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '114 KB', + limit: '116 KB', }, ]; diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index ac01ff0647a7..cf4c6dc6e9bb 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -170,6 +170,7 @@ export { statsigIntegration, unleashIntegration, growthbookIntegration, + spanStreamingIntegration, metrics, } from '@sentry/node'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index 2e51ab1b0f1e..8dfffdcbd852 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -20,6 +20,7 @@ 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 getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 1c980e4cae2d..b49b214b65d7 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -157,6 +157,7 @@ export { unleashIntegration, growthbookIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; export { diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index ab07f75c2b7d..9d1a296b5e57 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -27,17 +27,19 @@ export const spanStreamingIntegration = defineIntegration(() => { 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 = client.getOptions().beforeSendSpan; + 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)) { - client.getOptions().traceLifecycle = 'static'; + clientOptions.traceLifecycle = 'static'; DEBUG_BUILD && debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); return; diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index 5b84c52f8d7e..1d5d587290a3 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -50,25 +50,29 @@ describe('spanStreamingIntegration', () => { expect(client.getOptions().traceLifecycle).toBe('stream'); }); - it('logs a warning if traceLifecycle is not set to "stream"', () => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); - const client = new BrowserClient({ - ...getDefaultBrowserClientOptions(), - dsn: 'https://username@domain/123', - integrations: [spanStreamingIntegration()], - traceLifecycle: 'static', - }); - - 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.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(() => {}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c2990e6262a7..6f52a92ab6da 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -177,6 +177,7 @@ export { statsigIntegration, unleashIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a2af055232a1..539405f09adc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -184,6 +184,7 @@ export type { export { SpanBuffer } from './tracing/spans/spanBuffer'; export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; +export { spanStreamingIntegration } from './integrations/spanStreaming'; export type { FeatureFlag } from './utils/featureFlags'; diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts new file mode 100644 index 000000000000..541be6b7f5e9 --- /dev/null +++ b/packages/core/src/integrations/spanStreaming.ts @@ -0,0 +1,44 @@ +import type { IntegrationFn } from '../types-hoist/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'; + +export const spanStreamingIntegration = defineIntegration(() => { + return { + name: 'SpanStreaming', + + 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}`); + return; + } + + const buffer = new SpanBuffer(client); + + client.on('afterSpanEnd', span => { + if (!spanIsSampled(span)) { + return; + } + buffer.add(captureSpan(span, client)); + }); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts new file mode 100644 index 000000000000..8b2badf575ff --- /dev/null +++ b/packages/core/test/integrations/spanStreaming.test.ts @@ -0,0 +1,139 @@ +import * as SentryCore from '../../src'; +import { debug } from '../../src'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { spanStreamingIntegration } from '../../src/integrations/spanStreaming'; +import { TestClient, getDefaultTestClientOptions } from '../mocks/client'; + +const mockSpanBufferInstance = vi.hoisted(() => ({ + flush: vi.fn(), + add: vi.fn(), + drain: vi.fn(), +})); + +const MockSpanBuffer = vi.hoisted(() => { + return vi.fn(() => mockSpanBufferInstance); +}); + +vi.mock('../../src/tracing/spans/spanBuffer', async () => { + const original = await vi.importActual('../../src/tracing/spans/spanBuffer'); + return { + ...original, + SpanBuffer: MockSpanBuffer, + }; +}); + +describe('spanStreamingIntegration (core)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has the correct name and setup hook', () => { + const integration = spanStreamingIntegration(); + expect(integration.name).toBe('SpanStreaming'); + // eslint-disable-next-line @typescript-eslint/unbound-method + 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(() => {}); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + beforeSendSpan: (span: SentryCore.SpanJSON) => span, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('sets up buffer when traceLifecycle is "stream"', () => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(MockSpanBuffer).toHaveBeenCalledWith(client); + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('enqueues a span into the buffer when the span ends', () => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: true }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).toHaveBeenCalledWith( + expect.objectContaining({ + _segmentSpan: span, + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + name: 'test', + }), + ); + }); + + it('does not enqueue a span into the buffer when the span is not sampled', () => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test', sampled: false }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9478b98f5a58..a881f911e31c 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -157,6 +157,7 @@ export { statsigIntegration, unleashIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; export { diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 7c92fecd7834..1ac235711ca5 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -23,6 +23,7 @@ export declare function init( export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; // Different implementation in server and worker export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index 3fff4100b352..4877fc159e88 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -115,6 +115,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + spanStreamingIntegration, metrics, envToBool, } from '@sentry/core'; diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index ef9229f28de6..77b62e9ab2f9 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -12,6 +12,7 @@ import { linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -162,12 +163,18 @@ function getClientOptions( const integrations = options.integrations; const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions); + const resolvedIntegrations = getIntegrationsToSetup({ + defaultIntegrations, + integrations, + }); + + if (mergedOptions.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + return { ...mergedOptions, - integrations: getIntegrationsToSetup({ - defaultIntegrations, - integrations, - }), + integrations: resolvedIntegrations, }; } diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index de1569135cfd..5ae840e6e976 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -14,6 +14,7 @@ import { linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { @@ -212,12 +213,18 @@ function getClientOptions( const integrations = options.integrations; const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions); + const resolvedIntegrations = getIntegrationsToSetup({ + defaultIntegrations, + integrations, + }); + + if (mergedOptions.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + return { ...mergedOptions, - integrations: getIntegrationsToSetup({ - defaultIntegrations, - integrations, - }), + integrations: resolvedIntegrations, }; } diff --git a/packages/node-core/test/light/sdk.test.ts b/packages/node-core/test/light/sdk.test.ts index 8b0ef03700d9..48cac52022b0 100644 --- a/packages/node-core/test/light/sdk.test.ts +++ b/packages/node-core/test/light/sdk.test.ts @@ -106,6 +106,39 @@ describe('Light Mode | SDK', () => { expect(integrationNames).toContain('NodeFetch'); }); + + it('does not include spanStreaming integration', () => { + const integrations = Sentry.getDefaultIntegrations({ traceLifecycle: 'stream' }); + const integrationNames = integrations.map(i => i.name); + + expect(integrationNames).not.toContain('SpanStreaming'); + }); + }); + + describe('spanStreamingIntegration', () => { + it('installs spanStreaming integration when traceLifecycle is "stream"', () => { + const client = mockLightSdkInit({ traceLifecycle: 'stream' }); + const integrationNames = client?.getOptions().integrations.map(i => i.name); + + expect(integrationNames).toContain('SpanStreaming'); + }); + + it('does not install spanStreaming integration when traceLifecycle is not "stream"', () => { + const client = mockLightSdkInit(); + const integrationNames = client?.getOptions().integrations.map(i => i.name); + + expect(integrationNames).not.toContain('SpanStreaming'); + }); + + it('installs spanStreaming integration even with custom defaultIntegrations', () => { + const client = mockLightSdkInit({ + traceLifecycle: 'stream', + defaultIntegrations: [], + }); + const integrationNames = client?.getOptions().integrations.map(i => i.name); + + expect(integrationNames).toContain('SpanStreaming'); + }); }); describe('isInitialized', () => { diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts index 144ff3e2dc37..6ee986c3be75 100644 --- a/packages/node-core/test/sdk/init.test.ts +++ b/packages/node-core/test/sdk/init.test.ts @@ -81,6 +81,39 @@ describe('init()', () => { expect(mockIntegrations[1]?.setupOnce as Mock).toHaveBeenCalledTimes(1); }); + it('installs spanStreaming integration when traceLifecycle is "stream"', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream' }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it("doesn't install spanStreaming integration when traceLifecycle is not 'stream'", () => { + init({ dsn: PUBLIC_DSN }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it('installs spanStreaming integration even with custom defaultIntegrations', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', defaultIntegrations: [] }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + it('installs integrations returned from a callback function', () => { const mockDefaultIntegrations = [ new MockIntegration('Some mock integration 3.1'), diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8458dee5f6a7..9e1e892ed7ce 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -140,6 +140,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + spanStreamingIntegration, createLangChainCallbackHandler, instrumentLangGraph, instrumentStateGraphCompile, diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index a6a76a4439bd..26fe2d9933e6 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -143,6 +143,39 @@ describe('init()', () => { }), ); }); + + it('installs spanStreaming integration when traceLifecycle is "stream"', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream' }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it("doesn't install spanStreaming integration when traceLifecycle is not 'stream'", () => { + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + it('installs spanStreaming integration even with custom defaultIntegrations', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', defaultIntegrations: [] }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); }); describe('OpenTelemetry', () => { diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index 7109e7ad9c78..9a0283cfcee8 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -16,6 +16,7 @@ export * from './index.server'; export declare function init(options: Options | SentryNuxtClientOptions | SentryNuxtServerOptions): Client | undefined; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index 3430456caaee..b4b913c5535e 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -6,6 +6,7 @@ import { getClient, getDefaultCurrentScope, getDefaultIsolationScope, + hasSpanStreamingEnabled, logSpanEnd, logSpanStart, setCapturedScopesOnSpan, @@ -14,50 +15,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes import { SentrySpanExporter } from './spanExporter'; import { getScopesFromContext } from './utils/contextData'; import { setIsSetup } from './utils/setupCheck'; - -function onSpanStart(span: Span, parentContext: Context): void { - // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK - const parentSpan = trace.getSpan(parentContext); - - let scopes = getScopesFromContext(parentContext); - - // We need access to the parent span in order to be able to move up the span tree for breadcrumbs - if (parentSpan && !parentSpan.spanContext().isRemote) { - addChildSpanToSpan(parentSpan, span); - } - - // We need this in the span exporter - if (parentSpan?.spanContext().isRemote) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE, true); - } - - // The root context does not have scopes stored, so we check for this specifically - // As fallback we attach the global scopes - if (parentContext === ROOT_CONTEXT) { - scopes = { - scope: getDefaultCurrentScope(), - isolationScope: getDefaultIsolationScope(), - }; - } - - // We need the scope at time of span creation in order to apply it to the event when the span is finished - if (scopes) { - setCapturedScopesOnSpan(span, scopes.scope, scopes.isolationScope); - } - - logSpanStart(span); - - const client = getClient(); - client?.emit('spanStart', span); -} - -function onSpanEnd(span: Span): void { - logSpanEnd(span); - - const client = getClient(); - client?.emit('spanEnd', span); -} - /** * Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via * the Sentry SDK. @@ -88,13 +45,52 @@ export class SentrySpanProcessor implements SpanProcessorInterface { * @inheritDoc */ public onStart(span: Span, parentContext: Context): void { - onSpanStart(span, parentContext); + // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK + const parentSpan = trace.getSpan(parentContext); + + let scopes = getScopesFromContext(parentContext); + + // We need access to the parent span in order to be able to move up the span tree for breadcrumbs + if (parentSpan && !parentSpan.spanContext().isRemote) { + addChildSpanToSpan(parentSpan, span); + } + + // We need this in the span exporter + if (parentSpan?.spanContext().isRemote) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE, true); + } + + // The root context does not have scopes stored, so we check for this specifically + // As fallback we attach the global scopes + if (parentContext === ROOT_CONTEXT) { + scopes = { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + }; + } + + // We need the scope at time of span creation in order to apply it to the event when the span is finished + if (scopes) { + setCapturedScopesOnSpan(span, scopes.scope, scopes.isolationScope); + } + + logSpanStart(span); + + const client = getClient(); + client?.emit('spanStart', span); } /** @inheritDoc */ public onEnd(span: Span & ReadableSpan): void { - onSpanEnd(span); + logSpanEnd(span); + + const client = getClient(); + client?.emit('spanEnd', span); - this._exporter.export(span); + if (client && hasSpanStreamingEnabled(client)) { + client.emit('afterSpanEnd', span); + } else { + this._exporter.export(span); + } } } diff --git a/packages/opentelemetry/test/spanProcessor.test.ts b/packages/opentelemetry/test/spanProcessor.test.ts new file mode 100644 index 000000000000..2e3b0b5b999e --- /dev/null +++ b/packages/opentelemetry/test/spanProcessor.test.ts @@ -0,0 +1,57 @@ +import { getClient, startInactiveSpan } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SentrySpanExporter } from '../src/spanExporter'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +const exportSpy = vi.spyOn(SentrySpanExporter.prototype, 'export'); + +describe('SentrySpanProcessor', () => { + beforeEach(() => { + exportSpy.mockClear(); + }); + + describe('with traceLifecycle: static (default)', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('exports spans via the exporter', () => { + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + expect(exportSpy).toHaveBeenCalled(); + }); + }); + + describe('with traceLifecycle: stream', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1, traceLifecycle: 'stream' }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('does not export spans via the exporter', () => { + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + expect(exportSpy).not.toHaveBeenCalled(); + }); + + it('emits afterSpanEnd', () => { + const afterSpanEndCallback = vi.fn(); + const client = getClient()!; + client.on('afterSpanEnd', afterSpanEndCallback); + + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + expect(afterSpanEndCallback).toHaveBeenCalledWith(span); + }); + }); +}); diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index c9c5cb371763..4d0abcb6b0a8 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -16,6 +16,7 @@ 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 defaultStackParser: StackParser; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index 61d4f7e0b9bb..1cac41c1aacf 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -18,6 +18,7 @@ export declare function init(options: RemixOptions): Client | undefined; export declare const browserTracingIntegration: typeof clientSdk.browserTracingIntegration; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 1533a1ca7221..eccafe08a637 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -131,6 +131,7 @@ export { consoleLoggingIntegration, createConsolaReporter, createSentryWinstonTransport, + spanStreamingIntegration, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 4c5ff491c740..58e642ecba22 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -18,6 +18,7 @@ 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 getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 6e2bc1cb9f61..ffc73956a530 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -130,6 +130,7 @@ export { consoleLoggingIntegration, createConsolaReporter, createSentryWinstonTransport, + spanStreamingIntegration, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index d46e88e720ed..78cb27e2e47d 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -47,6 +47,7 @@ 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; // Different implementation in server and worker export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index d42975ef7876..8db039bb4369 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -135,6 +135,7 @@ export { createSentryWinstonTransport, vercelAIIntegration, metrics, + spanStreamingIntegration, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 7ab05bfc3831..0545fbf5c87b 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -18,6 +18,7 @@ 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 getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser;