From a5f7fe195bae86a4499fee823d359a1cd3356535 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Sep 2025 18:37:35 +0200 Subject: [PATCH 01/58] wip --- packages/core/src/client.ts | 11 +++++++++++ packages/core/src/tracing/sentrySpan.ts | 5 +++++ packages/core/src/types-hoist/options.ts | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index aad363905a68..4941188e1268 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -607,6 +607,14 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for after a span is ended. + * NOTE: The span cannot be mutated anymore in this callback. + * Receives the span as argument. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'segmentSpanEnd', callback: (span: Span) => 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. @@ -879,6 +887,9 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; + /** Fire a hook whenever a segment span ends. */ + public emit(hook: 'segmentSpanEnd', span: Span): void; + /** * Fire a hook indicating that an idle span is allowed to auto finish. */ diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 9bd98b9741c6..857d320e168e 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -298,6 +298,8 @@ export class SentrySpan implements Span { return; } + client?.emit('segmentSpanEnd', this); + // if this is a standalone span, we send it immediately if (this._isStandaloneSpan) { if (this._sampled) { @@ -310,6 +312,9 @@ export class SentrySpan implements Span { } } return; + } else if (client?.getOptions()._experiments?._INTERNAL_spanStreaming) { + // nothing to do here; the spanStreaming integration will listen to the respective client hook. + return; } const transactionEvent = this._convertSpanToTransaction(); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 3d4ad7b67ea5..d7e85177d31c 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -314,6 +314,14 @@ export interface ClientOptions Date: Wed, 1 Oct 2025 16:42:45 +0200 Subject: [PATCH 02/58] types, serialization, integration WIP --- .../browser/src/integrations/spanstreaming.ts | 105 ++++++++++++++++++ packages/core/src/client.ts | 7 +- packages/core/src/envelope.ts | 12 +- packages/core/src/index.ts | 3 + packages/core/src/tracing/sentrySpan.ts | 29 +++++ packages/core/src/types-hoist/attributes.ts | 20 ++++ packages/core/src/types-hoist/envelope.ts | 20 +++- packages/core/src/types-hoist/link.ts | 4 +- packages/core/src/types-hoist/options.ts | 20 +++- packages/core/src/types-hoist/span.ts | 19 ++++ packages/core/src/utils/attributes.ts | 49 ++++++++ packages/core/src/utils/beforeSendSpan.ts | 32 ++++++ packages/core/src/utils/spanUtils.ts | 102 ++++++++++++++++- 13 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 packages/browser/src/integrations/spanstreaming.ts create mode 100644 packages/core/src/types-hoist/attributes.ts create mode 100644 packages/core/src/utils/attributes.ts create mode 100644 packages/core/src/utils/beforeSendSpan.ts diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts new file mode 100644 index 000000000000..c19877b42bf3 --- /dev/null +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -0,0 +1,105 @@ +import type { Envelope, IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; +import { createEnvelope, debug, defineIntegration, isV2BeforeSendSpanCallback, spanToV2JSON } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +export interface SpanStreamingOptions { + batchLimit: number; +} + +const _spanStreamingIntegration = ((userOptions?: Partial) => { + const validatedUserProvidedBatchLimit = + userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1 + ? userOptions.batchLimit + : undefined; + + if (DEBUG_BUILD && userOptions?.batchLimit && !validatedUserProvidedBatchLimit) { + debug.warn('SpanStreaming batchLimit must be between 1 and 1000, defaulting to 1000'); + } + + const options: SpanStreamingOptions = { + batchLimit: + userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1 + ? userOptions.batchLimit + : 1000, + ...userOptions, + }; + + const traceMap = new Map>(); + + return { + name: 'SpanStreaming', + setup(client) { + const clientOptions = client.getOptions(); + const beforeSendSpan = clientOptions.beforeSendSpan; + + const initialMessage = 'spanStreamingIntegration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + + if (DEBUG_BUILD && clientOptions.traceLifecycle !== 'streamed') { + debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "streamed"! ${fallbackMsg}`); + return; + } + + if (DEBUG_BUILD && beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) { + debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`); + return; + } + + client.on('spanEnd', span => { + const spanBuffer = traceMap.get(span.spanContext().traceId); + if (spanBuffer) { + spanBuffer.add(span); + } else { + traceMap.set(span.spanContext().traceId, new Set([span])); + } + }); + + client.on('segmentSpanEnd', segmentSpan => { + const traceId = segmentSpan.spanContext().traceId; + const spansOfTrace = traceMap.get(traceId); + + if (!spansOfTrace?.size) { + traceMap.delete(traceId); + return; + } + + const serializedSpans = Array.from(spansOfTrace ?? []).map(span => { + const serializedSpan = spanToV2JSON(span); + const finalSpan = beforeSendSpan ? beforeSendSpan(serializedSpan) : serializedSpan; + return finalSpan; + }); + + const batches: SpanV2JSON[][] = []; + for (let i = 0; i < serializedSpans.length; i += options.batchLimit) { + batches.push(serializedSpans.slice(i, i + options.batchLimit)); + } + + debug.log(`Sending trace ${traceId} in ${batches.length} batche${batches.length === 1 ? '' : 's'}`); + + // TODO: Apply scopes to spans + + // TODO: Apply beforeSendSpan to spans + + // TODO: Apply ignoreSpans to spans + + for (const batch of batches) { + const envelope = createSpanStreamEnvelope(batch); + // no need to handle client reports for network errors, + // buffer overflows or rate limiting here. All of this is handled + // by client and transport. + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending span stream envelope:', reason); + }); + } + + traceMap.delete(traceId); + }); + }, + }; +}) satisfies IntegrationFn; + +export const spanStreamingIntegration = defineIntegration(_spanStreamingIntegration); + +function createSpanStreamEnvelope(serializedSpans: StreamedSpanJSON[]): Envelope { + return createEnvelope(headers, [item]); +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 4941188e1268..2ef118d0b5a2 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 { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; import { createClientReportEnvelope } from './utils/clientreport'; import { debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; @@ -1503,13 +1504,17 @@ function _validateBeforeSendResult( /** * Process the matching `beforeSendXXX` callback. */ +// eslint-disable-next-line complexity function processBeforeSend( client: Client, options: ClientOptions, event: Event, hint: EventHint, ): PromiseLike | Event | null { - const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options; + const { beforeSend, beforeSendTransaction, ignoreSpans } = options; + + const beforeSendSpan = !isV2BeforeSendSpanCallback(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..7bd2b9b8feec 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 { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; import { dsnToString } from './utils/dsn'; import { createEnvelope, @@ -138,7 +139,8 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; - const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {}; + const options = client?.getOptions(); + const ignoreSpans = options?.ignoreSpans; const filteredSpans = ignoreSpans?.length ? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans)) @@ -149,10 +151,14 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? client?.recordDroppedEvent('before_send', 'span', droppedSpans); } - const convertToSpanJSON = beforeSendSpan + // checking against traceLifeCycle so that TS can infer the correct type for + // beforeSendSpan. This is a workaround for now as most likely, this entire function + // will be removed in the future (once we send standalone spans as spans v2) + const convertToSpanJSON = options?.beforeSendSpan ? (span: SentrySpan) => { const spanJson = spanToJSON(span); - const processedSpan = beforeSendSpan(spanJson); + const processedSpan = + !isV2BeforeSendSpanCallback(options?.beforeSendSpan) && options?.beforeSendSpan?.(spanJson); if (!processedSpan) { showSpanDropWarning(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e18ea294f182..1f3f39cf1218 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,6 +86,7 @@ export { addChildSpanToSpan, spanTimeInputToSeconds, updateSpanName, + spanToV2JSON, } from './utils/spanUtils'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; @@ -323,6 +324,7 @@ export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; +export { isV2BeforeSendSpanCallback, makeV2Callback } from './utils/beforeSendSpan'; export type { Attachment } from './types-hoist/attachment'; export type { @@ -441,6 +443,7 @@ export type { SpanJSON, SpanContextData, TraceFlag, + SpanV2JSON, } 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/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 857d320e168e..e20f7a657dda 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -21,6 +21,7 @@ import type { SpanJSON, SpanOrigin, SpanTimeInput, + SpanV2JSON, } from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import type { TimedEvent } from '../types-hoist/timedEvent'; @@ -31,6 +32,9 @@ import { getRootSpan, getSpanDescendants, getStatusMessage, + getV2Attributes, + getV2SpanLinks, + getV2StatusMessage, spanTimeInputToSeconds, spanToJSON, spanToTransactionTraceContext, @@ -241,6 +245,31 @@ export class SentrySpan implements Span { }; } + /** + * Get SpanV2JSON 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 `spanToV2JSON(span)` instead. + */ + public getSpanV2JSON(): SpanV2JSON { + 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_remote: false, // TODO: This has to be inferred from attributes SentrySpans. `false` is the default. + kind: 'internal', // TODO: This has to be inferred from attributes SentrySpans. `internal` is the default. + status: getV2StatusMessage(this._status), + attributes: getV2Attributes(this._attributes), + links: getV2SpanLinks(this._links), + }; + } + /** @inheritdoc */ public isRecording(): boolean { return !this._endTime && !!this._sampled; diff --git a/packages/core/src/types-hoist/attributes.ts b/packages/core/src/types-hoist/attributes.ts new file mode 100644 index 000000000000..56b3658f8c20 --- /dev/null +++ b/packages/core/src/types-hoist/attributes.ts @@ -0,0 +1,20 @@ +export type SerializedAttributes = Record; +export type SerializedAttribute = ( + | { + type: 'string'; + value: string; + } + | { + type: 'integer'; + value: number; + } + | { + type: 'double'; + value: number; + } + | { + type: 'boolean'; + value: boolean; + } +) & { unit?: 'ms' | 's' | 'bytes' | 'count' | 'percent' }; +export type SerializedAttributeType = 'string' | 'integer' | 'double' | 'boolean'; diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 272f8cde9f62..a11f931ca103 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 SpanV2ItemHeaders = { + /** + * Same as v1 span item type but this envelope is distinguished by {@link SpanV2ItemHeaders.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 SpanV2Item = 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; 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/options.ts b/packages/core/src/types-hoist/options.ts index d7e85177d31c..7e42bc6635ed 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, SpanV2JSON } from './span'; import type { StackLineParser, StackParser } from './stacktrace'; import type { TracePropagationTargets } from './tracing'; import type { BaseTransportOptions, Transport } from './transport'; @@ -404,6 +404,16 @@ export interface ClientOptions SpanJSON; + beforeSendSpan?: ((span: SpanJSON) => SpanJSON) | SpanV2CompatibleBeforeSendSpanCallback; /** * An event-processing callback for transaction events, guaranteed to be invoked after all other event @@ -519,6 +529,12 @@ export interface ClientOptions Breadcrumb | null; } +/** + * A callback that is known to be compatible with actually receiving and returning a span v2 JSON object. + * Only useful in conjunction with the {@link CoreOptions.traceLifecycle} option. + */ +export type SpanV2CompatibleBeforeSendSpanCallback = ((span: SpanV2JSON) => SpanV2JSON) & { _v2: true }; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit>, 'integrations' | 'transport' | 'stackParser'> { diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index d82463768b7f..9304ede5ab39 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,3 +1,4 @@ +import { SerializedAttributes } from './attributes'; import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; @@ -34,6 +35,24 @@ export type SpanAttributes = Partial<{ /** This type is aligned with the OpenTelemetry TimeInput type. */ export type SpanTimeInput = HrTime | number | Date; +export interface SpanV2JSON { + trace_id: string; + parent_span_id?: string; + span_id: string; + name: string; + start_timestamp: number; + end_timestamp: number; + status: 'ok' | 'error'; + kind: 'server' | 'client' | 'internal' | 'consumer' | 'producer'; + is_remote: boolean; + attributes?: SerializedAttributes; + links?: SpanLinkJSON[]; +} + +export type SerializedSpanContainer = { + items: Array; +}; + /** A JSON representation of a span. */ export interface SpanJSON { data: SpanAttributes; diff --git a/packages/core/src/utils/attributes.ts b/packages/core/src/utils/attributes.ts new file mode 100644 index 000000000000..e9ce603bf5dd --- /dev/null +++ b/packages/core/src/utils/attributes.ts @@ -0,0 +1,49 @@ +import type { SerializedAttribute } from '../types-hoist/attributes'; + +/** + * Converts an attribute value to a serialized attribute value object, containing + * a type descriptor as well as the value. + * + * TODO: dedupe this with the logs version of the function (didn't do this yet to avoid + * dependance on logs/spans for the open questions RE array and object attribute types) + * + * @param value - The value of the log attribute. + * @returns The serialized log attribute. + */ +export function attributeValueToSerializedAttribute(value: unknown): SerializedAttribute { + switch (typeof value) { + case 'number': + if (Number.isInteger(value)) { + return { + value, + type: 'integer', + }; + } + return { + value, + type: 'double', + }; + case 'boolean': + return { + value, + type: 'boolean', + }; + case 'string': + return { + value, + type: 'string', + }; + default: { + let stringValue = ''; + try { + stringValue = JSON.stringify(value) ?? ''; + } catch { + // Do nothing + } + return { + value: stringValue, + type: 'string', + }; + } + } +} diff --git a/packages/core/src/utils/beforeSendSpan.ts b/packages/core/src/utils/beforeSendSpan.ts new file mode 100644 index 000000000000..7f04bc269b3b --- /dev/null +++ b/packages/core/src/utils/beforeSendSpan.ts @@ -0,0 +1,32 @@ +import type { ClientOptions, SpanV2CompatibleBeforeSendSpanCallback } from '../types-hoist/options'; +import type { SpanV2JSON } from '../types-hoist/span'; +import { addNonEnumerableProperty } from './object'; + +/** + * A wrapper to use the new span format in your `beforeSendSpan` callback. + * + * @example + * + * Sentry.init({ + * beforeSendSpan: makeV2Callback((span) => { + * return span; + * }), + * }); + * + * @param callback + * @returns + */ +export function makeV2Callback(callback: (span: SpanV2JSON) => SpanV2JSON): SpanV2CompatibleBeforeSendSpanCallback { + addNonEnumerableProperty(callback, '_v2', true); + // type-casting here because TS can't infer the type correctly + return callback as SpanV2CompatibleBeforeSendSpanCallback; +} + +/** + * Typesafe check to identify the expected span json format of the `beforeSendSpan` callback. + */ +export function isV2BeforeSendSpanCallback( + callback: ClientOptions['beforeSendSpan'], +): callback is SpanV2CompatibleBeforeSendSpanCallback { + return !!callback && '_v2' in callback && !!callback._v2; +} diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index d7c261ecd73c..f46f52f056d1 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -8,16 +8,18 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; -import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; +import type { SerializedAttributes } from '../types-hoist/attributes'; 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 { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput, SpanV2JSON } from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; import { timestampInSeconds } from '../utils/time'; import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing'; +import { attributeValueToSerializedAttribute } from './attributes'; import { consoleSandbox } from './debug-logger'; import { _getSpanForScope } from './spanOnScope'; @@ -92,7 +94,7 @@ export function spanToTraceparentHeader(span: Span): string { * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent. */ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] | undefined { - if (links && links.length > 0) { + if (links?.length) { return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ span_id: spanId, trace_id: traceId, @@ -104,6 +106,24 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] return undefined; } } +/** + * + * @param links + * @returns + */ +export function getV2SpanLinks(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 && { attributes: getV2Attributes(attributes) }), + ...restContext, + })); + } else { + return undefined; + } +} /** * Convert a span time input into a timestamp in seconds. @@ -187,6 +207,61 @@ export function spanToJSON(span: Span): SpanJSON { }; } +/** + * Convert a span to a SpanV2JSON representation. + * @returns + */ +export function spanToV2JSON(span: Span): SpanV2JSON { + if (spanIsSentrySpan(span)) { + return span.getSpanV2JSON(); + } + + const { spanId: span_id, traceId: trace_id, isRemote } = span.spanContext(); + + // Handle a span from @opentelemetry/sdk-base-trace's `Span` class + 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 { + name, + span_id, + trace_id, + parent_span_id: parentSpanId, + start_timestamp: spanTimeInputToSeconds(startTime), + end_timestamp: spanTimeInputToSeconds(endTime), + is_remote: isRemote || false, + kind: 'internal', // TODO: Figure out how to get this from the OTel span as it's not publicly exposed + status: getV2StatusMessage(status), + attributes: getV2Attributes(attributes), + links: getV2SpanLinks(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', + kind: 'internal', + is_remote: isRemote || false, + }; +} + 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 +312,27 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef return status.message || 'internal_error'; } +/** + * Convert the various statuses to the ones expected by Sentry ('ok' is default) + */ +export function getV2StatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { + return !status || + status.code === SPAN_STATUS_UNSET || + (status.code === SPAN_STATUS_ERROR && status.message === 'unknown_error') + ? 'ok' + : 'error'; +} + +/** + * Convert the attributes to the ones expected by Sentry, including the type annotation + */ +export function getV2Attributes(attributes: SpanAttributes): SerializedAttributes { + return Object.entries(attributes).reduce((acc, [key, value]) => { + acc[key] = attributeValueToSerializedAttribute(value); + return acc; + }, {} as SerializedAttributes); +} + const CHILD_SPANS_FIELD = '_sentryChildSpans'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; From 4721ca6cc5c4e8343d7ed5cf3deff8ca2408554a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 1 Oct 2025 17:55:04 +0200 Subject: [PATCH 03/58] create span v2 envelope --- .../browser/src/integrations/spanstreaming.ts | 39 +++++++++++-------- packages/core/src/envelope.ts | 38 ++++++++++++++++-- packages/core/src/index.ts | 1 + packages/core/src/types-hoist/envelope.ts | 11 +++--- packages/core/src/types-hoist/span.ts | 2 +- 5 files changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index c19877b42bf3..201386a9a28a 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,6 +1,13 @@ -import type { Envelope, IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; -import { createEnvelope, debug, defineIntegration, isV2BeforeSendSpanCallback, spanToV2JSON } from '@sentry/core'; +import type { IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; +import { + debug, + defineIntegration, + getDynamicSamplingContextFromSpan, + isV2BeforeSendSpanCallback, + spanToV2JSON, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { createSpanV2Envelope } from '@sentry/core/build/types/envelope'; export interface SpanStreamingOptions { batchLimit: number; @@ -35,13 +42,14 @@ const _spanStreamingIntegration = ((userOptions?: Partial) const initialMessage = 'spanStreamingIntegration requires'; const fallbackMsg = 'Falling back to static trace lifecycle.'; - if (DEBUG_BUILD && clientOptions.traceLifecycle !== 'streamed') { - debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "streamed"! ${fallbackMsg}`); + if (clientOptions.traceLifecycle !== 'streamed') { + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "streamed"! ${fallbackMsg}`); return; } - if (DEBUG_BUILD && beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) { - debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`); + if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) { + DEBUG_BUILD && + debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`); return; } @@ -54,6 +62,8 @@ const _spanStreamingIntegration = ((userOptions?: Partial) } }); + // For now, we send all spans on local segment (root) span end. + // TODO: This will change once we have more concrete ideas about a universal SDK data buffer. client.on('segmentSpanEnd', segmentSpan => { const traceId = segmentSpan.spanContext().traceId; const spansOfTrace = traceMap.get(traceId); @@ -65,8 +75,7 @@ const _spanStreamingIntegration = ((userOptions?: Partial) const serializedSpans = Array.from(spansOfTrace ?? []).map(span => { const serializedSpan = spanToV2JSON(span); - const finalSpan = beforeSendSpan ? beforeSendSpan(serializedSpan) : serializedSpan; - return finalSpan; + return beforeSendSpan ? beforeSendSpan(serializedSpan) : serializedSpan; }); const batches: SpanV2JSON[][] = []; @@ -74,16 +83,16 @@ const _spanStreamingIntegration = ((userOptions?: Partial) batches.push(serializedSpans.slice(i, i + options.batchLimit)); } - debug.log(`Sending trace ${traceId} in ${batches.length} batche${batches.length === 1 ? '' : 's'}`); + DEBUG_BUILD && + debug.log(`Sending trace ${traceId} in ${batches.length} batche${batches.length === 1 ? '' : 's'}`); // TODO: Apply scopes to spans - - // TODO: Apply beforeSendSpan to spans - // TODO: Apply ignoreSpans to spans + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + for (const batch of batches) { - const envelope = createSpanStreamEnvelope(batch); + const envelope = createSpanV2Envelope(batch, dsc, client); // no need to handle client reports for network errors, // buffer overflows or rate limiting here. All of this is handled // by client and transport. @@ -99,7 +108,3 @@ const _spanStreamingIntegration = ((userOptions?: Partial) }) satisfies IntegrationFn; export const spanStreamingIntegration = defineIntegration(_spanStreamingIntegration); - -function createSpanStreamEnvelope(serializedSpans: StreamedSpanJSON[]): Envelope { - return createEnvelope(headers, [item]); -} diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 7bd2b9b8feec..515ff4fde859 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -11,13 +11,16 @@ import type { RawSecurityItem, SessionEnvelope, SessionItem, + SpanContainerItem, SpanEnvelope, SpanItem, + SpanV2Envelope, } from './types-hoist/envelope'; 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 { SpanV2JSON } from './types-hoist/span'; import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; import { dsnToString } from './utils/dsn'; import { @@ -121,10 +124,6 @@ export function createEventEnvelope( * Takes an optional client and runs spans through `beforeSendSpan` if available. */ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?: Client): SpanEnvelope { - function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { - return !!dsc.trace_id && !!dsc.public_key; - } - // For the moment we'll obtain the DSC from the first span in the array // This might need to be changed if we permit sending multiple spans from // different segments in one envelope @@ -180,6 +179,33 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? return createEnvelope(headers, items); } +/** + * Creates a span v2 envelope + */ +export function createSpanV2Envelope( + serializedSpans: SpanV2JSON[], + dsc: Partial, + client: Client, +): SpanV2Envelope { + const dsn = client?.getDsn(); + const tunnel = client?.getOptions().tunnel; + const sdk = client?.getOptions()._metadata?.sdk; + + const headers: SpanV2Envelope[0] = { + sent_at: new Date().toISOString(), + ...(dscHasRequiredProps(dsc) && { trace: dsc }), + ...(sdk && { 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]); +} + /** * Create an Envelope from a CSP report. */ @@ -202,3 +228,7 @@ export function createRawSecurityEnvelope( return createEnvelope(envelopeHeaders, [eventItem]); } + +function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { + return !!dsc.trace_id && !!dsc.public_key; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1f3f39cf1218..ef6e59a5b836 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -376,6 +376,7 @@ export type { ProfileChunkEnvelope, ProfileChunkItem, SpanEnvelope, + SpanV2Envelope, SpanItem, LogEnvelope, MetricEnvelope, diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index a11f931ca103..7251f85b5df0 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -91,9 +91,9 @@ type CheckInItemHeaders = { type: 'check_in' }; type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type SpanItemHeaders = { type: 'span' }; -type SpanV2ItemHeaders = { +type SpanContainerItemHeaders = { /** - * Same as v1 span item type but this envelope is distinguished by {@link SpanV2ItemHeaders.content_type}. + * Same as v1 span item type but this envelope is distinguished by {@link SpanContainerItemHeaders.content_type}. */ type: 'span'; /** @@ -138,7 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; -export type SpanV2Item = 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 SpanV2EnvelopeHeaders = 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 SpanV2Envelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -175,6 +175,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope + | SpanV2Envelope | RawSecurityEnvelope | LogEnvelope | MetricEnvelope; diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index 9304ede5ab39..762d3519d0fe 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,4 +1,4 @@ -import { SerializedAttributes } from './attributes'; +import type { SerializedAttributes } from './attributes'; import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; From 6a1e628268225d7ea3c6813d99ff1fedc86ad5b6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 2 Oct 2025 10:27:29 +0200 Subject: [PATCH 04/58] exports --- packages/browser/src/index.ts | 1 + packages/browser/src/integrations/spanstreaming.ts | 2 +- packages/core/src/index.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 6e7c54198edc..0450627051cf 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -83,3 +83,4 @@ export { growthbookIntegration } from './integrations/featureFlags/growthbook'; export { statsigIntegration } from './integrations/featureFlags/statsig'; export { diagnoseSdkConnectivity } from './diagnose-sdk'; export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker'; +export { spanStreamingIntegration } from './integrations/spanstreaming'; diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 201386a9a28a..9ad4222d3069 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,5 +1,6 @@ import type { IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; import { + createSpanV2Envelope, debug, defineIntegration, getDynamicSamplingContextFromSpan, @@ -7,7 +8,6 @@ import { spanToV2JSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import { createSpanV2Envelope } from '@sentry/core/build/types/envelope'; export interface SpanStreamingOptions { batchLimit: number; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ef6e59a5b836..21e896ae16a2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,7 +9,7 @@ export type { IntegrationIndex } from './integration'; export * from './tracing'; export * from './semanticAttributes'; -export { createEventEnvelope, createSessionEnvelope, createSpanEnvelope } from './envelope'; +export { createEventEnvelope, createSessionEnvelope, createSpanEnvelope, createSpanV2Envelope } from './envelope'; export { captureCheckIn, withMonitor, From 05411c6d2bf73c0b2ce0dc2e0f9316723ae175d6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 2 Oct 2025 16:46:09 +0200 Subject: [PATCH 05/58] apply ignorespans, improve beforesendspan, handle segment span being dropped --- .../browser/src/integrations/spanstreaming.ts | 146 +++++++++++++----- packages/core/src/index.ts | 3 + packages/core/src/utils/should-ignore-span.ts | 44 ++++-- packages/core/src/utils/spanUtils.ts | 7 +- 4 files changed, 148 insertions(+), 52 deletions(-) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 9ad4222d3069..c16b8b0e9488 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,10 +1,14 @@ -import type { IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; +import type { Client, IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; import { createSpanV2Envelope, debug, defineIntegration, getDynamicSamplingContextFromSpan, + getRootSpan as getSegmentSpan, isV2BeforeSendSpanCallback, + reparentChildSpans, + shouldIgnoreSpan, + showSpanDropWarning, spanToV2JSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -13,7 +17,7 @@ export interface SpanStreamingOptions { batchLimit: number; } -const _spanStreamingIntegration = ((userOptions?: Partial) => { +export const spanStreamingIntegration = defineIntegration(((userOptions?: Partial) => { const validatedUserProvidedBatchLimit = userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1 ? userOptions.batchLimit @@ -31,7 +35,8 @@ const _spanStreamingIntegration = ((userOptions?: Partial) ...userOptions, }; - const traceMap = new Map>(); + // key: traceId-segmentSpanId + const spanTreeMap = new Map>(); return { name: 'SpanStreaming', @@ -54,57 +59,118 @@ const _spanStreamingIntegration = ((userOptions?: Partial) } client.on('spanEnd', span => { - const spanBuffer = traceMap.get(span.spanContext().traceId); + const spanTreeMapKey = getSpanTreeMapKey(span); + const spanBuffer = spanTreeMap.get(spanTreeMapKey); if (spanBuffer) { spanBuffer.add(span); } else { - traceMap.set(span.spanContext().traceId, new Set([span])); + spanTreeMap.set(spanTreeMapKey, new Set([span])); } }); // For now, we send all spans on local segment (root) span end. // TODO: This will change once we have more concrete ideas about a universal SDK data buffer. - client.on('segmentSpanEnd', segmentSpan => { - const traceId = segmentSpan.spanContext().traceId; - const spansOfTrace = traceMap.get(traceId); + client.on( + 'segmentSpanEnd', + segmentSpan => () => + processAndSendSpans(segmentSpan, { + spanTreeMap: spanTreeMap, + client, + batchLimit: options.batchLimit, + beforeSendSpan, + }), + ); + }, + }; +}) satisfies IntegrationFn); - if (!spansOfTrace?.size) { - traceMap.delete(traceId); - return; - } +interface SpanProcessingOptions { + client: Client; + spanTreeMap: Map>; + batchLimit: number; + beforeSendSpan: ((span: SpanV2JSON) => SpanV2JSON) | undefined; +} - const serializedSpans = Array.from(spansOfTrace ?? []).map(span => { - const serializedSpan = spanToV2JSON(span); - return beforeSendSpan ? beforeSendSpan(serializedSpan) : serializedSpan; - }); +function getSpanTreeMapKey(span: Span): string { + return `${span.spanContext().traceId}-${getSegmentSpan(span).spanContext().spanId}`; +} - const batches: SpanV2JSON[][] = []; - for (let i = 0; i < serializedSpans.length; i += options.batchLimit) { - batches.push(serializedSpans.slice(i, i + options.batchLimit)); - } +function processAndSendSpans( + segmentSpan: Span, + { client, spanTreeMap, batchLimit, beforeSendSpan }: SpanProcessingOptions, +): void { + const traceId = segmentSpan.spanContext().traceId; + const spanTreeMapKey = getSpanTreeMapKey(segmentSpan); + const spansOfTrace = spanTreeMap.get(spanTreeMapKey); + + if (!spansOfTrace?.size) { + spanTreeMap.delete(spanTreeMapKey); + return; + } - DEBUG_BUILD && - debug.log(`Sending trace ${traceId} in ${batches.length} batche${batches.length === 1 ? '' : 's'}`); + const { ignoreSpans } = client.getOptions(); - // TODO: Apply scopes to spans - // TODO: Apply ignoreSpans to spans + // TODO: Apply scopes to spans - const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + // 1. Check if the entire span tree is ignored by ignoreSpans + const segmentSpanJson = spanToV2JSON(segmentSpan); + if (ignoreSpans?.length && shouldIgnoreSpan(segmentSpanJson, ignoreSpans)) { + client.recordDroppedEvent('before_send', 'span', spansOfTrace.size); + spanTreeMap.delete(spanTreeMapKey); + return; + } - for (const batch of batches) { - const envelope = createSpanV2Envelope(batch, dsc, client); - // no need to handle client reports for network errors, - // buffer overflows or rate limiting here. All of this is handled - // by client and transport. - client.sendEnvelope(envelope).then(null, reason => { - DEBUG_BUILD && debug.error('Error while sending span stream envelope:', reason); - }); - } + const serializedSpans = Array.from(spansOfTrace ?? []).map(spanToV2JSON); - traceMap.delete(traceId); - }); - }, - }; -}) satisfies IntegrationFn; + const processedSpans = []; + let ignoredSpanCount = 0; + + for (const span of serializedSpans) { + // 2. Check if child spans should be ignored + const isChildSpan = span.span_id !== segmentSpan.spanContext().spanId; + if (ignoreSpans?.length && isChildSpan && shouldIgnoreSpan(span, ignoreSpans)) { + reparentChildSpans(serializedSpans, span); + ignoredSpanCount++; + // drop this span by not adding it to the processedSpans array + continue; + } -export const spanStreamingIntegration = defineIntegration(_spanStreamingIntegration); + // 3. Apply beforeSendSpan callback + const processedSpan = beforeSendSpan ? applyBeforeSendSpanCallback(span, beforeSendSpan) : span; + processedSpans.push(processedSpan); + } + + if (ignoredSpanCount) { + client.recordDroppedEvent('before_send', 'span', ignoredSpanCount); + } + + const batches: SpanV2JSON[][] = []; + for (let i = 0; i < processedSpans.length; i += batchLimit) { + batches.push(processedSpans.slice(i, i + batchLimit)); + } + + DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batche${batches.length === 1 ? '' : 's'}`); + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + for (const batch of batches) { + const envelope = createSpanV2Envelope(batch, dsc, client); + // no need to handle client reports for network errors, + // buffer overflows or rate limiting here. All of this is handled + // by client and transport. + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending span stream envelope:', reason); + }); + } + + spanTreeMap.delete(spanTreeMapKey); +} + +function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON { + const modifedSpan = beforeSendSpan(span); + if (!modifedSpan) { + showSpanDropWarning(); + return span; + } + return modifedSpan; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 21e896ae16a2..b7e416a9a9a3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -82,11 +82,13 @@ export { getSpanDescendants, getStatusMessage, getRootSpan, + getSegmentSpan, getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, updateSpanName, spanToV2JSON, + showSpanDropWarning, } from './utils/spanUtils'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; @@ -325,6 +327,7 @@ export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; export { isV2BeforeSendSpanCallback, makeV2Callback } from './utils/beforeSendSpan'; +export { shouldIgnoreSpan, reparentChildSpans } from './utils/should-ignore-span'; export type { Attachment } from './types-hoist/attachment'; export type { diff --git a/packages/core/src/utils/should-ignore-span.ts b/packages/core/src/utils/should-ignore-span.ts index a8d3ac0211c7..f05f0dc5402e 100644 --- a/packages/core/src/utils/should-ignore-span.ts +++ b/packages/core/src/utils/should-ignore-span.ts @@ -1,28 +1,47 @@ import { DEBUG_BUILD } from '../debug-build'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; import type { ClientOptions } from '../types-hoist/options'; -import type { SpanJSON } from '../types-hoist/span'; +import type { SpanJSON, SpanV2JSON } from '../types-hoist/span'; import { debug } from './debug-logger'; import { isMatchingPattern } from './string'; -function logIgnoredSpan(droppedSpan: Pick): void { - debug.log(`Ignoring span ${droppedSpan.op} - ${droppedSpan.description} because it matches \`ignoreSpans\`.`); +function logIgnoredSpan(spanName: string, spanOp: string | undefined): void { + debug.log(`Ignoring span ${spanOp ? `${spanOp} - ` : ''}${spanName} because it matches \`ignoreSpans\`.`); } /** * Check if a span should be ignored based on the ignoreSpans configuration. */ export function shouldIgnoreSpan( - span: Pick, + span: Pick | Pick, ignoreSpans: Required['ignoreSpans'], ): boolean { - if (!ignoreSpans?.length || !span.description) { + if (!ignoreSpans?.length) { + return false; + } + + const { spanName, spanOp: spanOpAttributeOrString } = + 'description' in span + ? { spanName: span.description, spanOp: span.op } + : 'name' in span + ? { spanName: span.name, spanOp: span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] } + : { spanName: '', spanOp: '' }; + + const spanOp = + typeof spanOpAttributeOrString === 'string' + ? spanOpAttributeOrString + : spanOpAttributeOrString?.type === 'string' + ? spanOpAttributeOrString.value + : undefined; + + if (!spanName) { return false; } for (const pattern of ignoreSpans) { if (isStringOrRegExp(pattern)) { - if (isMatchingPattern(span.description, pattern)) { - DEBUG_BUILD && logIgnoredSpan(span); + if (isMatchingPattern(spanName, pattern)) { + DEBUG_BUILD && logIgnoredSpan(spanName, spanOp); return true; } continue; @@ -32,15 +51,15 @@ export function shouldIgnoreSpan( continue; } - const nameMatches = pattern.name ? isMatchingPattern(span.description, pattern.name) : true; - const opMatches = pattern.op ? span.op && isMatchingPattern(span.op, pattern.op) : true; + const nameMatches = pattern.name ? isMatchingPattern(spanName, pattern.name) : true; + const opMatches = pattern.op ? spanOp && isMatchingPattern(spanOp, pattern.op) : true; // This check here is only correct because we can guarantee that we ran `isMatchingPattern` // for at least one of `nameMatches` and `opMatches`. So in contrary to how this looks, // not both op and name actually have to match. This is the most efficient way to check // for all combinations of name and op patterns. if (nameMatches && opMatches) { - DEBUG_BUILD && logIgnoredSpan(span); + DEBUG_BUILD && logIgnoredSpan(spanName, spanOp); return true; } } @@ -52,7 +71,10 @@ export function shouldIgnoreSpan( * Takes a list of spans, and a span that was dropped, and re-parents the child spans of the dropped span to the parent of the dropped span, if possible. * This mutates the spans array in place! */ -export function reparentChildSpans(spans: SpanJSON[], dropSpan: SpanJSON): void { +export function reparentChildSpans( + spans: Pick[], + dropSpan: Pick, +): void { const droppedSpanParentId = dropSpan.parent_span_id; const droppedSpanId = dropSpan.span_id; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index f46f52f056d1..1311d3720d7b 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -394,7 +394,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] { /** * Returns the root span of a given span. */ -export function getRootSpan(span: SpanWithPotentialChildren): Span { +export const getRootSpan = getSegmentSpan; + +/** + * Returns the segment span of a given span. + */ +export function getSegmentSpan(span: SpanWithPotentialChildren): Span { return span[ROOT_SPAN_FIELD] || span; } From 91573dc3d39eccad73509a91924a048266170b59 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 3 Oct 2025 12:20:40 +0200 Subject: [PATCH 06/58] apply common attributes --- .size-limit.js | 7 ++ .../browser/src/integrations/spanstreaming.ts | 70 +++++++++++++++++-- packages/core/src/semanticAttributes.ts | 30 ++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index aca0f6ed7eaf..e30314153fbd 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -47,6 +47,13 @@ module.exports = [ gzip: true, limit: '48 KB', }, + { + name: '@sentry/browser (incl. Tracing with Span Streaming)', + path: 'packages/browser/build/npm/esm/index.js', + import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), + gzip: true, + limit: '41.5 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/prod/index.js', diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index c16b8b0e9488..5dd2f11f77bc 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,12 +1,24 @@ -import type { Client, IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; +import type { Client, IntegrationFn, Span, SpanAttributes, SpanAttributeValue, SpanV2JSON } from '@sentry/core'; import { createSpanV2Envelope, debug, defineIntegration, + getCapturedScopesOnSpan, getDynamicSamplingContextFromSpan, + getGlobalScope, getRootSpan as getSegmentSpan, isV2BeforeSendSpanCallback, + mergeScopeData, reparentChildSpans, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, shouldIgnoreSpan, showSpanDropWarning, spanToV2JSON, @@ -91,6 +103,9 @@ interface SpanProcessingOptions { beforeSendSpan: ((span: SpanV2JSON) => SpanV2JSON) | undefined; } +/** + * Just the traceid alone isn't enough because there can be multiple span trees with the same traceid. + */ function getSpanTreeMapKey(span: Span): string { return `${span.spanContext().traceId}-${getSegmentSpan(span).spanContext().spanId}`; } @@ -107,13 +122,17 @@ function processAndSendSpans( spanTreeMap.delete(spanTreeMapKey); return; } + const segmentSpanJson = spanToV2JSON(segmentSpan); - const { ignoreSpans } = client.getOptions(); + for (const span of spansOfTrace) { + applyCommonSpanAttributes(span, segmentSpanJson, client); + } - // TODO: Apply scopes to spans + // TODO: Apply scope data and contexts to segment span + + const { ignoreSpans } = client.getOptions(); // 1. Check if the entire span tree is ignored by ignoreSpans - const segmentSpanJson = spanToV2JSON(segmentSpan); if (ignoreSpans?.length && shouldIgnoreSpan(segmentSpanJson, ignoreSpans)) { client.recordDroppedEvent('before_send', 'span', spansOfTrace.size); spanTreeMap.delete(spanTreeMapKey); @@ -166,6 +185,41 @@ function processAndSendSpans( spanTreeMap.delete(spanTreeMapKey); } +function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void { + const sdk = client.getSdkMetadata(); + const { release, environment, sendDefaultPii } = client.getOptions(); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + + const originalAttributeKeys = Object.keys(spanToV2JSON(span).attributes ?? {}); + + // TODO: Extract this scope data merge to a helper in core. It's used in multiple places. + const finalScopeData = getGlobalScope().getScopeData(); + if (spanIsolationScope) { + mergeScopeData(finalScopeData, spanIsolationScope.getScopeData()); + } + if (spanScope) { + mergeScopeData(finalScopeData, spanScope.getScopeData()); + } + + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + setAttributesIfNotPresent(span, originalAttributeKeys, { + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, + ...(sendDefaultPii + ? { + [SEMANTIC_ATTRIBUTE_USER_ID]: finalScopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: finalScopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: finalScopeData.user?.ip_address ?? undefined, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: finalScopeData.user?.username, + } + : {}), + }); +} + function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON { const modifedSpan = beforeSendSpan(span); if (!modifedSpan) { @@ -174,3 +228,11 @@ function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: Sp } return modifedSpan; } + +function setAttributesIfNotPresent(span: Span, originalAttributeKeys: string[], newAttributes: SpanAttributes): void { + Object.keys(newAttributes).forEach(key => { + if (!originalAttributeKeys.includes(key)) { + span.setAttribute(key, newAttributes[key]); + } + }); +} diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 9b90809c0091..df43f510aaaf 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -77,3 +77,33 @@ export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; * @see https://develop.sentry.dev/sdk/telemetry/traces/span-links/#link-types */ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; + +// some attributes for now exclusively used for span streaming +// @see https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#common-attribute-keys + +/** 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 operating system name (e.g., "Linux", "Windows", "macOS") */ +export const SEMANTIC_ATTRIBUTE_OS_NAME = 'os.name'; +/** The browser name (e.g., "Chrome", "Firefox", "Safari") */ +export const SEMANTIC_ATTRIBUTE_BROWSER_VERSION = 'browser.name'; +/** 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.username'; +/** The thread ID */ +export const SEMANTIC_ATTRIBUTE_THREAD_ID = 'thread.id'; +/** The thread name */ +export const SEMANTIC_ATTRIBUTE_THREAD_NAME = 'thread.name'; +/** 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'; From 2c4caae23f23d1e6dd61c68424fc558c4a98c78f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 3 Oct 2025 12:22:47 +0200 Subject: [PATCH 07/58] linter really doesn't like me and I can't blame him --- packages/core/src/tracing/sentrySpan.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index e20f7a657dda..258f8fb86dae 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'; From b478c42bf400b1dcbffc043598e3c4cea17e656b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 3 Oct 2025 18:05:40 +0200 Subject: [PATCH 08/58] apply scope contexts, extras, request data attributes --- .../browser/src/integrations/spanstreaming.ts | 97 +++++++++++++----- packages/core/src/index.ts | 1 + packages/core/src/tracing/sentrySpan.ts | 7 +- packages/core/src/types-hoist/options.ts | 2 +- packages/core/src/utils/attributes.ts | 51 ++++++++++ .../core/test/lib/utils/attributes.test.ts | 99 +++++++++++++++++++ 6 files changed, 226 insertions(+), 31 deletions(-) create mode 100644 packages/core/test/lib/utils/attributes.test.ts diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 5dd2f11f77bc..94b4bb0665a5 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,5 +1,6 @@ -import type { Client, IntegrationFn, Span, SpanAttributes, SpanAttributeValue, SpanV2JSON } from '@sentry/core'; +import type { Client, IntegrationFn, Scope, ScopeData, Span, SpanAttributes, SpanV2JSON } from '@sentry/core'; import { + attributesFromObject, createSpanV2Envelope, debug, defineIntegration, @@ -7,14 +8,17 @@ import { getDynamicSamplingContextFromSpan, getGlobalScope, getRootSpan as getSegmentSpan, + httpHeadersToSpanAttributes, isV2BeforeSendSpanCallback, mergeScopeData, reparentChildSpans, + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_URL_FULL, SEMANTIC_ATTRIBUTE_USER_EMAIL, SEMANTIC_ATTRIBUTE_USER_ID, SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, @@ -24,6 +28,7 @@ import { spanToV2JSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { getHttpRequestData } from '../helpers'; export interface SpanStreamingOptions { batchLimit: number; @@ -40,11 +45,11 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia } const options: SpanStreamingOptions = { + ...userOptions, batchLimit: userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1 ? userOptions.batchLimit : 1000, - ...userOptions, }; // key: traceId-segmentSpanId @@ -59,14 +64,14 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia const initialMessage = 'spanStreamingIntegration requires'; const fallbackMsg = 'Falling back to static trace lifecycle.'; - if (clientOptions.traceLifecycle !== 'streamed') { - DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "streamed"! ${fallbackMsg}`); + if (clientOptions.traceLifecycle !== 'stream') { + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); return; } if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) { - DEBUG_BUILD && - debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`); + client.getOptions().traceLifecycle = 'static'; + debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`); return; } @@ -82,16 +87,14 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia // For now, we send all spans on local segment (root) span end. // TODO: This will change once we have more concrete ideas about a universal SDK data buffer. - client.on( - 'segmentSpanEnd', - segmentSpan => () => - processAndSendSpans(segmentSpan, { - spanTreeMap: spanTreeMap, - client, - batchLimit: options.batchLimit, - beforeSendSpan, - }), - ); + client.on('segmentSpanEnd', segmentSpan => { + processAndSendSpans(segmentSpan, { + spanTreeMap: spanTreeMap, + client, + batchLimit: options.batchLimit, + beforeSendSpan, + }); + }); }, }; }) satisfies IntegrationFn); @@ -122,12 +125,15 @@ function processAndSendSpans( spanTreeMap.delete(spanTreeMapKey); return; } + const segmentSpanJson = spanToV2JSON(segmentSpan); for (const span of spansOfTrace) { applyCommonSpanAttributes(span, segmentSpanJson, client); } + applyScopeToSegmentSpan(segmentSpan, segmentSpanJson, client); + // TODO: Apply scope data and contexts to segment span const { ignoreSpans } = client.getOptions(); @@ -139,7 +145,12 @@ function processAndSendSpans( return; } - const serializedSpans = Array.from(spansOfTrace ?? []).map(spanToV2JSON); + const serializedSpans = Array.from(spansOfTrace ?? []).map(s => { + const serialized = spanToV2JSON(s); + // remove internal span attributes we don't need to send. + delete serialized.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + return serialized; + }); const processedSpans = []; let ignoredSpanCount = 0; @@ -168,7 +179,7 @@ function processAndSendSpans( batches.push(processedSpans.slice(i, i + batchLimit)); } - DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batche${batches.length === 1 ? '' : 's'}`); + DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batch${batches.length === 1 ? '' : 'es'}`); const dsc = getDynamicSamplingContextFromSpan(segmentSpan); @@ -193,14 +204,7 @@ function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON const originalAttributeKeys = Object.keys(spanToV2JSON(span).attributes ?? {}); - // TODO: Extract this scope data merge to a helper in core. It's used in multiple places. - const finalScopeData = getGlobalScope().getScopeData(); - if (spanIsolationScope) { - mergeScopeData(finalScopeData, spanIsolationScope.getScopeData()); - } - if (spanScope) { - mergeScopeData(finalScopeData, spanScope.getScopeData()); - } + const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope); // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) setAttributesIfNotPresent(span, originalAttributeKeys, { @@ -220,6 +224,35 @@ function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON }); } +/** + * Adds span attributes frome + */ +function applyScopeToSegmentSpan(segmentSpan: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void { + const { isolationScope, scope } = getCapturedScopesOnSpan(segmentSpan); + const finalScopeData = getFinalScopeData(isolationScope, scope); + + const browserRequestData = getHttpRequestData(); + + const tags = finalScopeData.tags ?? {}; + + let contextAttributes = {}; + Object.keys(finalScopeData.contexts).forEach(key => { + if (finalScopeData.contexts[key]) { + contextAttributes = { ...contextAttributes, ...attributesFromObject(finalScopeData.contexts[key]) }; + } + }); + + const extraAttributes = attributesFromObject(finalScopeData.extra); + + setAttributesIfNotPresent(segmentSpan, Object.keys(serializedSegmentSpan.attributes ?? {}), { + [SEMANTIC_ATTRIBUTE_URL_FULL]: browserRequestData.url, + ...httpHeadersToSpanAttributes(browserRequestData.headers, client.getOptions().sendDefaultPii ?? false), + ...tags, + ...contextAttributes, + ...extraAttributes, + }); +} + function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON { const modifedSpan = beforeSendSpan(span); if (!modifedSpan) { @@ -236,3 +269,15 @@ function setAttributesIfNotPresent(span: Span, originalAttributeKeys: string[], } }); } + +// TODO: Extract this to a helper in core. It's used in multiple places. +function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | undefined): ScopeData { + const finalScopeData = getGlobalScope().getScopeData(); + if (isolationScope) { + mergeScopeData(finalScopeData, isolationScope.getScopeData()); + } + if (scope) { + mergeScopeData(finalScopeData, scope.getScopeData()); + } + return finalScopeData; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b7e416a9a9a3..d412d2c9ccb4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,6 +90,7 @@ export { spanToV2JSON, showSpanDropWarning, } from './utils/spanUtils'; +export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 258f8fb86dae..6a4eaefb21f5 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -328,8 +328,6 @@ export class SentrySpan implements Span { return; } - client?.emit('segmentSpanEnd', this); - // if this is a standalone span, we send it immediately if (this._isStandaloneSpan) { if (this._sampled) { @@ -342,8 +340,9 @@ export class SentrySpan implements Span { } } return; - } else if (client?.getOptions()._experiments?._INTERNAL_spanStreaming) { - // nothing to do here; the spanStreaming integration will listen to the respective client hook. + } else if (client?.getOptions().traceLifecycle === 'stream') { + // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans + client?.emit('segmentSpanEnd', this); return; } diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 7e42bc6635ed..70e51cb8e4a9 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -412,7 +412,7 @@ export interface ClientOptions, maxDepth = 3): SpanAttributes { + const result: Record = {}; + + function primitiveOrToString(current: unknown): number | boolean | string { + if (typeof current === 'number' || typeof current === 'boolean' || typeof current === 'string') { + return current; + } + return String(current); + } + + function flatten(current: unknown, prefix: string, depth: number): void { + if (current == null) { + return; + } else if (depth >= maxDepth) { + result[prefix] = primitiveOrToString(current); + return; + } else if (Array.isArray(current)) { + result[prefix] = JSON.stringify(current); + } else if (typeof current === 'number' || typeof current === 'string' || typeof current === 'boolean') { + result[prefix] = current; + } else if (typeof current === 'object' && current !== null && !Array.isArray(current) && depth < maxDepth) { + for (const [key, value] of Object.entries(current as Record)) { + flatten(value, prefix ? `${prefix}.${key}` : key, depth + 1); + } + } + } + + const normalizedObj = normalize(obj, maxDepth); + + flatten(normalizedObj, '', 0); + + return result; +} diff --git a/packages/core/test/lib/utils/attributes.test.ts b/packages/core/test/lib/utils/attributes.test.ts new file mode 100644 index 000000000000..9dd05e0e5b28 --- /dev/null +++ b/packages/core/test/lib/utils/attributes.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { attributesFromObject } from '../../../src/utils/attributes'; + +describe('attributesFromObject', () => { + it('flattens an object', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + }; + + const result = attributesFromObject(context); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + }); + }); + + it('flattens an object with a max depth', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + }; + + const result = attributesFromObject(context, 2); + + expect(result).toEqual({ + a: 1, + 'b.c': '[Object]', + }); + }); + + it('flattens an object an array', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + integrations: ['foo', 'bar'], + }; + + const result = attributesFromObject(context); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + integrations: '["foo","bar"]', + }); + }); + + it('handles a circular object', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + }; + context.b.c.e = context.b; + + const result = attributesFromObject(context, 5); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + 'b.c.e': '[Circular ~]', + }); + }); + + it('handles a circular object in an array', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + integrations: ['foo', 'bar'], + }; + + // @ts-expect-error - this is fine + context.integrations[0] = context.integrations; + + const result = attributesFromObject(context, 5); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + integrations: '["[Circular ~]","bar"]', + }); + }); + + it('handles objects in arrays', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + integrations: [{ name: 'foo' }, { name: 'bar' }], + }; + + const result = attributesFromObject(context); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + integrations: '[{"name":"foo"},{"name":"bar"}]', + }); + }); +}); From a0d7e950149cd49de7b7184099b04b108cd0158c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 14 Oct 2025 18:01:14 +0200 Subject: [PATCH 09/58] cleanup --- packages/browser/src/integrations/spanstreaming.ts | 9 ++++++--- packages/core/src/types-hoist/options.ts | 8 -------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 94b4bb0665a5..7b1bbc96889c 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -225,7 +225,9 @@ function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON } /** - * Adds span attributes frome + * Adds span attributes from the scopes' contexts + * TODO: It's not set in stone yet if we actually want to flatmap contexts into span attributes. + * For now we do it but not yet extra or tags. It's still TBD how to proceed here. */ function applyScopeToSegmentSpan(segmentSpan: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void { const { isolationScope, scope } = getCapturedScopesOnSpan(segmentSpan); @@ -237,8 +239,9 @@ function applyScopeToSegmentSpan(segmentSpan: Span, serializedSegmentSpan: SpanV let contextAttributes = {}; Object.keys(finalScopeData.contexts).forEach(key => { - if (finalScopeData.contexts[key]) { - contextAttributes = { ...contextAttributes, ...attributesFromObject(finalScopeData.contexts[key]) }; + const context = finalScopeData.contexts[key]; + if (context) { + contextAttributes = { ...contextAttributes, ...attributesFromObject(context) }; } }); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 70e51cb8e4a9..2c0bdec60681 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -314,14 +314,6 @@ export interface ClientOptions Date: Tue, 14 Oct 2025 18:30:39 +0200 Subject: [PATCH 10/58] changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcb48fb1a83..78965bf49680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -647,6 +647,10 @@ Work in this release was contributed by @0xbad0c0d3. Thank you for your contribu Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez. Thank you for your contributions! +## 10.20.0-alpha.0 + +This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852) + ## 10.19.0 - feat(tracemetrics): Add trace metrics behind an experiments flag ([#17883](https://github.com/getsentry/sentry-javascript/pull/17883)) From ae5ccfb6d8ad2e211b90672feac95f56eecdce16 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 14 Oct 2025 18:34:05 +0200 Subject: [PATCH 11/58] size-limit bumps --- .size-limit.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index e30314153fbd..55e2e6ec7ed3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -52,7 +52,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), gzip: true, - limit: '41.5 KB', + limit: '44 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', @@ -110,7 +110,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '30 KB', + limit: '31 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '30 KB', + limit: '31 KB', }, { name: '@sentry/vue (incl. Tracing)', @@ -229,7 +229,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42 KB', + limit: '43 KB', }, // Node-Core SDK (ESM) { From da9851e041fd976057978a701a0eac285a2f0e41 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 14 Oct 2025 19:05:09 +0200 Subject: [PATCH 12/58] fix lint, circular deps, size limit --- packages/core/src/envelope.ts | 2 +- packages/core/src/utils/attributes.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 515ff4fde859..aa392314d1db 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -20,7 +20,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 { SpanV2JSON } from './types-hoist/span'; +import type { SpanV2JSON } from './types-hoist/span'; import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; import { dsnToString } from './utils/dsn'; import { diff --git a/packages/core/src/utils/attributes.ts b/packages/core/src/utils/attributes.ts index d24e949df693..99419ce1afbd 100644 --- a/packages/core/src/utils/attributes.ts +++ b/packages/core/src/utils/attributes.ts @@ -1,8 +1,6 @@ -import { normalize } from '..'; import type { SerializedAttribute } from '../types-hoist/attributes'; -import { Primitive } from '../types-hoist/misc'; -import type { SpanAttributes, SpanAttributeValue } from '../types-hoist/span'; -import { isPrimitive } from './is'; +import type { SpanAttributes } from '../types-hoist/span'; +import { normalize } from '../utils/normalize'; /** * Converts an attribute value to a serialized attribute value object, containing From 8caf686d0078e27fa83c22ca540ea76cc9abe094 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 15 Oct 2025 17:11:46 +0200 Subject: [PATCH 13/58] bump preview version --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78965bf49680..ca268a98a3f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -647,7 +647,7 @@ Work in this release was contributed by @0xbad0c0d3. Thank you for your contribu Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez. Thank you for your contributions! -## 10.20.0-alpha.0 +## 10.21.0-alpha.0 This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852) From 30dc334aa8e167dffb2729bdbe54b030ec3bffc9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 15 Oct 2025 17:21:42 +0200 Subject: [PATCH 14/58] s/makeV2Callback/withStreamSpan --- packages/browser/src/integrations/spanstreaming.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/utils/beforeSendSpan.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 7b1bbc96889c..beb0639604a4 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -71,7 +71,7 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) { client.getOptions().traceLifecycle = 'static'; - debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`); + debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamSpan\`! ${fallbackMsg}`); return; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d412d2c9ccb4..d4f7e4def4d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -327,7 +327,7 @@ export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; -export { isV2BeforeSendSpanCallback, makeV2Callback } from './utils/beforeSendSpan'; +export { isV2BeforeSendSpanCallback, withStreamSpan } from './utils/beforeSendSpan'; export { shouldIgnoreSpan, reparentChildSpans } from './utils/should-ignore-span'; export type { Attachment } from './types-hoist/attachment'; diff --git a/packages/core/src/utils/beforeSendSpan.ts b/packages/core/src/utils/beforeSendSpan.ts index 7f04bc269b3b..3bfe2fa0c301 100644 --- a/packages/core/src/utils/beforeSendSpan.ts +++ b/packages/core/src/utils/beforeSendSpan.ts @@ -8,7 +8,7 @@ import { addNonEnumerableProperty } from './object'; * @example * * Sentry.init({ - * beforeSendSpan: makeV2Callback((span) => { + * beforeSendSpan: withStreamSpan((span) => { * return span; * }), * }); @@ -16,7 +16,7 @@ import { addNonEnumerableProperty } from './object'; * @param callback * @returns */ -export function makeV2Callback(callback: (span: SpanV2JSON) => SpanV2JSON): SpanV2CompatibleBeforeSendSpanCallback { +export function withStreamSpan(callback: (span: SpanV2JSON) => SpanV2JSON): SpanV2CompatibleBeforeSendSpanCallback { addNonEnumerableProperty(callback, '_v2', true); // type-casting here because TS can't infer the type correctly return callback as SpanV2CompatibleBeforeSendSpanCallback; From f59aac8fb9fd022a8b351d9212f0d72afc8ca591 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 15 Oct 2025 18:21:41 +0200 Subject: [PATCH 15/58] add todos for event processors and integration hooks --- packages/astro/src/server/sdk.ts | 1 + packages/browser/src/integrations/httpcontext.ts | 2 ++ packages/browser/src/tracing/request.ts | 1 + packages/core/src/integrations/eventFilters.ts | 2 +- packages/core/src/integrations/requestdata.ts | 2 ++ packages/core/src/tracing/vercel-ai/index.ts | 1 + packages/core/src/utils/featureFlags.ts | 1 + packages/deno/src/integrations/context.ts | 1 + packages/nextjs/src/client/index.ts | 2 ++ packages/nextjs/src/server/index.ts | 3 +++ packages/node-core/src/integrations/context.ts | 1 + .../src/integrations/http/httpServerSpansIntegration.ts | 1 + packages/node-core/src/integrations/http/index.ts | 1 + packages/nuxt/src/server/sdk.ts | 1 + .../integration/lowQualityTransactionsFilterIntegration.ts | 1 + .../react-router/src/server/integration/reactRouterServer.ts | 1 + packages/solidstart/src/server/utils.ts | 1 + .../sveltekit/src/server-common/integrations/svelteKitSpans.ts | 1 + 18 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 25dbb9416fe6..7e9597f9f39e 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -15,6 +15,7 @@ export function init(options: NodeOptions): NodeClient | undefined { const client = initNodeSdk(opts); + // TODO (span-streaming): remove this event processor. In this case, can probably just disable http integration server spans client?.addEventProcessor( Object.assign( (event: Event) => { diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 9517b2364e83..254e867301af 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -8,6 +8,8 @@ import { getHttpRequestData, WINDOW } from '../helpers'; export const httpContextIntegration = defineIntegration(() => { return { name: 'HttpContext', + // TODO (span-streaming): probably fine to omit this in favour of us globally + // already adding request context data but should double-check this preprocessEvent(event) { // if none of the information we want exists, don't bother if (!WINDOW.navigator && !WINDOW.location && !WINDOW.document) { diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 025b08b12168..692fc131230b 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -156,6 +156,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { if (event.type === 'transaction' && event.spans) { event.spans.forEach(span => { diff --git a/packages/core/src/integrations/eventFilters.ts b/packages/core/src/integrations/eventFilters.ts index 84ae5d4c4139..4278d234a0f9 100644 --- a/packages/core/src/integrations/eventFilters.ts +++ b/packages/core/src/integrations/eventFilters.ts @@ -145,7 +145,7 @@ function _shouldDropEvent(event: Event, options: Partial): } } else if (event.type === 'transaction') { // Filter transactions - + // TODO (span-streaming): replace with ignoreSpans defaults (if we have any) if (_isIgnoredTransaction(event, options.ignoreTransactions)) { DEBUG_BUILD && debug.warn( diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index a72fbed70d7e..5a45bc9c9861 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -40,6 +40,8 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return { name: INTEGRATION_NAME, + // TODO (span-streaming): probably fine to leave as-is for errors. + // For spans, we go through global context -> attribute conversion or omit this completely (TBD) processEvent(event, _hint, client) { const { sdkProcessingMetadata = {} } = event; const { normalizedRequest, ipAddress } = sdkProcessingMetadata; diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 93be1ca33423..d8cf5f2753fa 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -74,6 +74,7 @@ function onVercelAiSpanStart(span: Span): void { processGenerateSpan(span, name, attributes); } +// TODO (span-streaming): move to client hook. What to do about parent modifications? function vercelAiEventProcessor(event: Event): Event { if (event.type === 'transaction' && event.spans) { // Map to accumulate token data by parent span ID diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index 4fa3cdc5ac8d..671f615b32cf 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -27,6 +27,7 @@ const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; /** * Copies feature flags that are in current scope context to the event context */ +// TODO (span-streaming): should flags be added to (segment) spans? If so, probably do this via globally applying context data to spans export function _INTERNAL_copyFlagsFromScopeToEvent(event: Event): Event { const scope = getCurrentScope(); const flagContext = scope.getScopeData().contexts.flags; diff --git a/packages/deno/src/integrations/context.ts b/packages/deno/src/integrations/context.ts index 979ffff7d0e8..4dc9c723fbeb 100644 --- a/packages/deno/src/integrations/context.ts +++ b/packages/deno/src/integrations/context.ts @@ -56,6 +56,7 @@ const _denoContextIntegration = (() => { return { name: INTEGRATION_NAME, processEvent(event) { + // TODO (span-streaming): we probably need to apply this to spans via a hook IF we decide to apply contexts to (segment) spans return addDenoRuntimeContext(event); }, }; diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 07d1ee5c4e84..7906818766b7 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -66,11 +66,13 @@ export function init(options: BrowserOptions): Client | undefined { const client = reactInit(opts); + // TODO (span-streaming): replace with ignoreSpans default? const filterTransactions: EventProcessor = event => event.type === 'transaction' && event.transaction === '/404' ? null : event; filterTransactions.id = 'NextClient404Filter'; addEventProcessor(filterTransactions); + // TODO (span-streaming): replace with ignoreSpans default? const filterIncompleteNavigationTransactions: EventProcessor = event => event.type === 'transaction' && event.transaction === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME ? null diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 7dc533e171b1..1e57257f0d56 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -162,6 +162,9 @@ export function init(options: NodeOptions): NodeClient | undefined { client?.on('spanStart', handleOnSpanStart); + // TODO (span-streaming): + // - replace with ignoreSpans default + // - allow ignoreSpans to filter on arbitrary span attributes (not just op) getGlobalScope().addEventProcessor( Object.assign( (event => { diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index cad8a1c4a443..16cdadd9383b 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -107,6 +107,7 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { return { name: INTEGRATION_NAME, + // TODO (span-streaming): we probably need to apply this to spans via a hook IF we decide to apply contexts to (segment) spans processEvent(event) { return addContext(event); }, diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 7909482a5923..4834968cbe68 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -219,6 +219,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions }, processEvent(event) { // Drop transaction if it has a status code that should be ignored + // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default if (event.type === 'transaction') { const statusCode = event.contexts?.trace?.data?.['http.response.status_code']; if (typeof statusCode === 'number') { diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 19859b68f3c0..30ae4a468323 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -167,6 +167,7 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => instrumentSentryHttp(httpInstrumentationOptions); }, + // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default; check with serverSpans migration strategy processEvent(event) { // Note: We always run this, even if spans are disabled // The reason being that e.g. the remix integration disables span creation here but still wants to use the ignore status codes option diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 2b492b1249ac..b5639cd13da6 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -37,6 +37,7 @@ export function init(options: SentryNuxtServerOptions): Client | undefined { * * Only exported for testing */ +// TODO (span-streaming): replace with ignoreSpans default export function lowQualityTransactionsFilter(options: SentryNuxtServerOptions): EventProcessor { return Object.assign( (event => { diff --git a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts index e4471167f7ce..dd91820af152 100644 --- a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts +++ b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts @@ -15,6 +15,7 @@ function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { return { name: 'LowQualityTransactionsFilter', + // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default; processEvent(event: Event, _hint: EventHint, _client: Client): Event | null { if (event.type !== 'transaction' || !event.transaction) { return event; diff --git a/packages/react-router/src/server/integration/reactRouterServer.ts b/packages/react-router/src/server/integration/reactRouterServer.ts index 4625d1cb979e..10b3fe0ddbd7 100644 --- a/packages/react-router/src/server/integration/reactRouterServer.ts +++ b/packages/react-router/src/server/integration/reactRouterServer.ts @@ -30,6 +30,7 @@ export const reactRouterServerIntegration = defineIntegration(() => { instrumentReactRouterServer(); } }, + // TODO (span-streaming): port this logic to spans via a hook or ignoreSpans default; processEvent(event) { // Express generates bogus `*` routes for data loaders, which we want to remove here // we cannot do this earlier because some OTEL instrumentation adds this at some unexpected point diff --git a/packages/solidstart/src/server/utils.ts b/packages/solidstart/src/server/utils.ts index 8276c32da9e0..0d838c601827 100644 --- a/packages/solidstart/src/server/utils.ts +++ b/packages/solidstart/src/server/utils.ts @@ -44,5 +44,6 @@ export function lowQualityTransactionsFilter(options: Options): EventProcessor { * e.g. to filter out transactions for build assets */ export function filterLowQualityTransactions(options: Options): void { + // TODO (span-streaming): replace with ignoreSpans defaults getGlobalScope().addEventProcessor(lowQualityTransactionsFilter(options)); } diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts index c38108c75542..92eca161d38f 100644 --- a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts +++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts @@ -11,6 +11,7 @@ export function svelteKitSpansIntegration(): Integration { name: 'SvelteKitSpansEnhancement', // Using preprocessEvent to ensure the processing happens before user-configured // event processors are executed + // TODO (span-streaming): replace with client hook preprocessEvent(event) { // only iterate over the spans if the root span was emitted by SvelteKit // TODO: Right now, we can't optimize this to only check traces with a kit-emitted root span From 39c306263ad44e6585d6887ceef65e42b5d2dcb0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Oct 2025 14:06:01 +0200 Subject: [PATCH 16/58] changelog --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca268a98a3f8..6b160371c7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -607,6 +607,10 @@ Work in this release was contributed by @hanseo0507. Thank you for your contribu Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! +## 10.21.0-alpha.0 + +This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852) + ## 10.20.0 ### Important Changes @@ -647,10 +651,6 @@ Work in this release was contributed by @0xbad0c0d3. Thank you for your contribu Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez. Thank you for your contributions! -## 10.21.0-alpha.0 - -This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852) - ## 10.19.0 - feat(tracemetrics): Add trace metrics behind an experiments flag ([#17883](https://github.com/getsentry/sentry-javascript/pull/17883)) From a852db863bd83bfe3c5988912d3af41d0b13cd59 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Oct 2025 16:06:07 +0200 Subject: [PATCH 17/58] export withStreamSpan from browser --- packages/browser/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 0450627051cf..fdf089c5bb1a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -51,6 +51,7 @@ export { startInactiveSpan, startSpanManual, withActiveSpan, + withStreamSpan, startNewTrace, getSpanDescendants, setMeasurement, From f18934814e644fba57c0a11c8ab19fae7edf8236 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Oct 2025 16:06:55 +0200 Subject: [PATCH 18/58] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b160371c7bd..b008ee117ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -607,6 +607,12 @@ Work in this release was contributed by @hanseo0507. Thank you for your contribu Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! +## 10.21.0-alpha.1 + +This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852) + +- export withStreamSpan from `@sentry/browser` + ## 10.21.0-alpha.0 This release is a preview release for sending spans in browser via spanV2 instead of transaction event envelopes. All of this is experimental and subject to change. Use at your own risk. [More Details.](https://github.com/getsentry/sentry-javascript/pull/17852) From a889cbae143536e0179dce88d5416f7afc4d4f91 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 31 Oct 2025 10:23:42 +0100 Subject: [PATCH 19/58] fix some attribute mishaps --- packages/core/src/semanticAttributes.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index df43f510aaaf..edabcce0e24d 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -86,11 +86,7 @@ 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 operating system name (e.g., "Linux", "Windows", "macOS") */ -export const SEMANTIC_ATTRIBUTE_OS_NAME = 'os.name'; -/** The browser name (e.g., "Chrome", "Firefox", "Safari") */ -export const SEMANTIC_ATTRIBUTE_BROWSER_VERSION = 'browser.name'; +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment.name'; /** The user ID (gated by sendDefaultPii) */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; /** The user email (gated by sendDefaultPii) */ @@ -99,10 +95,6 @@ export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; /** The user username (gated by sendDefaultPii) */ export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.username'; -/** The thread ID */ -export const SEMANTIC_ATTRIBUTE_THREAD_ID = 'thread.id'; -/** The thread name */ -export const SEMANTIC_ATTRIBUTE_THREAD_NAME = 'thread.name'; /** 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 */ From bd3992488d254321687514a93755ff00435a6d4b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 10 Nov 2025 17:14:25 +0100 Subject: [PATCH 20/58] remove is_remote, add is_segment --- .../browser/src/integrations/spanstreaming.ts | 45 ++----------------- packages/core/src/index.ts | 2 +- packages/core/src/tracing/sentrySpan.ts | 2 +- packages/core/src/types-hoist/span.ts | 2 +- packages/core/src/utils/spanUtils.ts | 10 ++--- 5 files changed, 11 insertions(+), 50 deletions(-) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index beb0639604a4..d46d960390d4 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,14 +1,12 @@ import type { Client, IntegrationFn, Scope, ScopeData, Span, SpanAttributes, SpanV2JSON } from '@sentry/core'; import { - attributesFromObject, createSpanV2Envelope, debug, defineIntegration, getCapturedScopesOnSpan, getDynamicSamplingContextFromSpan, getGlobalScope, - getRootSpan as getSegmentSpan, - httpHeadersToSpanAttributes, + INTERNAL_getSegmentSpan, isV2BeforeSendSpanCallback, mergeScopeData, reparentChildSpans, @@ -18,7 +16,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, - SEMANTIC_ATTRIBUTE_URL_FULL, SEMANTIC_ATTRIBUTE_USER_EMAIL, SEMANTIC_ATTRIBUTE_USER_ID, SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, @@ -28,7 +25,6 @@ import { spanToV2JSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import { getHttpRequestData } from '../helpers'; export interface SpanStreamingOptions { batchLimit: number; @@ -110,7 +106,7 @@ interface SpanProcessingOptions { * Just the traceid alone isn't enough because there can be multiple span trees with the same traceid. */ function getSpanTreeMapKey(span: Span): string { - return `${span.spanContext().traceId}-${getSegmentSpan(span).spanContext().spanId}`; + return `${span.spanContext().traceId}-${INTERNAL_getSegmentSpan(span).spanContext().spanId}`; } function processAndSendSpans( @@ -132,10 +128,6 @@ function processAndSendSpans( applyCommonSpanAttributes(span, segmentSpanJson, client); } - applyScopeToSegmentSpan(segmentSpan, segmentSpanJson, client); - - // TODO: Apply scope data and contexts to segment span - const { ignoreSpans } = client.getOptions(); // 1. Check if the entire span tree is ignored by ignoreSpans @@ -166,6 +158,7 @@ function processAndSendSpans( } // 3. Apply beforeSendSpan callback + // TODO: validate beforeSendSpan result/pass in a copy and merge afterwards const processedSpan = beforeSendSpan ? applyBeforeSendSpanCallback(span, beforeSendSpan) : span; processedSpans.push(processedSpan); } @@ -224,38 +217,6 @@ function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON }); } -/** - * Adds span attributes from the scopes' contexts - * TODO: It's not set in stone yet if we actually want to flatmap contexts into span attributes. - * For now we do it but not yet extra or tags. It's still TBD how to proceed here. - */ -function applyScopeToSegmentSpan(segmentSpan: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void { - const { isolationScope, scope } = getCapturedScopesOnSpan(segmentSpan); - const finalScopeData = getFinalScopeData(isolationScope, scope); - - const browserRequestData = getHttpRequestData(); - - const tags = finalScopeData.tags ?? {}; - - let contextAttributes = {}; - Object.keys(finalScopeData.contexts).forEach(key => { - const context = finalScopeData.contexts[key]; - if (context) { - contextAttributes = { ...contextAttributes, ...attributesFromObject(context) }; - } - }); - - const extraAttributes = attributesFromObject(finalScopeData.extra); - - setAttributesIfNotPresent(segmentSpan, Object.keys(serializedSegmentSpan.attributes ?? {}), { - [SEMANTIC_ATTRIBUTE_URL_FULL]: browserRequestData.url, - ...httpHeadersToSpanAttributes(browserRequestData.headers, client.getOptions().sendDefaultPii ?? false), - ...tags, - ...contextAttributes, - ...extraAttributes, - }); -} - function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON { const modifedSpan = beforeSendSpan(span); if (!modifedSpan) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4f7e4def4d4..df22511d73db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -82,7 +82,7 @@ export { getSpanDescendants, getStatusMessage, getRootSpan, - getSegmentSpan, + INTERNAL_getSegmentSpan, getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 6a4eaefb21f5..8e48baa10281 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -263,7 +263,7 @@ export class SentrySpan implements Span { 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_remote: false, // TODO: This has to be inferred from attributes SentrySpans. `false` is the default. + is_segment: this._isStandaloneSpan || this === getRootSpan(this), kind: 'internal', // TODO: This has to be inferred from attributes SentrySpans. `internal` is the default. status: getV2StatusMessage(this._status), attributes: getV2Attributes(this._attributes), diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index 762d3519d0fe..bdc1fb5477d8 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -44,7 +44,7 @@ export interface SpanV2JSON { end_timestamp: number; status: 'ok' | 'error'; kind: 'server' | 'client' | 'internal' | 'consumer' | 'producer'; - is_remote: boolean; + is_segment: boolean; attributes?: SerializedAttributes; links?: SpanLinkJSON[]; } diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 1311d3720d7b..5c26e3bb0a2d 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -216,7 +216,7 @@ export function spanToV2JSON(span: Span): SpanV2JSON { return span.getSpanV2JSON(); } - const { spanId: span_id, traceId: trace_id, isRemote } = span.spanContext(); + const { spanId: span_id, traceId: trace_id } = span.spanContext(); // Handle a span from @opentelemetry/sdk-base-trace's `Span` class if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { @@ -240,7 +240,7 @@ export function spanToV2JSON(span: Span): SpanV2JSON { parent_span_id: parentSpanId, start_timestamp: spanTimeInputToSeconds(startTime), end_timestamp: spanTimeInputToSeconds(endTime), - is_remote: isRemote || false, + is_segment: span === INTERNAL_getSegmentSpan(span), kind: 'internal', // TODO: Figure out how to get this from the OTel span as it's not publicly exposed status: getV2StatusMessage(status), attributes: getV2Attributes(attributes), @@ -258,7 +258,7 @@ export function spanToV2JSON(span: Span): SpanV2JSON { end_timestamp: 0, status: 'ok', kind: 'internal', - is_remote: isRemote || false, + is_segment: span === INTERNAL_getSegmentSpan(span), }; } @@ -394,12 +394,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] { /** * Returns the root span of a given span. */ -export const getRootSpan = getSegmentSpan; +export const getRootSpan = INTERNAL_getSegmentSpan; /** * Returns the segment span of a given span. */ -export function getSegmentSpan(span: SpanWithPotentialChildren): Span { +export function INTERNAL_getSegmentSpan(span: SpanWithPotentialChildren): Span { return span[ROOT_SPAN_FIELD] || span; } From 62b18756a14f7496df506e9a5e6a42be0c5530b0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 10 Nov 2025 17:15:57 +0100 Subject: [PATCH 21/58] add `sentry.segment.id` common span attribute --- packages/browser/src/integrations/spanstreaming.ts | 2 ++ packages/core/src/semanticAttributes.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index d46d960390d4..2dba8e8d0899 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -15,6 +15,7 @@ import { 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_USER_EMAIL, SEMANTIC_ATTRIBUTE_USER_ID, @@ -204,6 +205,7 @@ function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON [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 diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index edabcce0e24d..019f5ae5e020 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -87,6 +87,8 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; 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 user ID (gated by sendDefaultPii) */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; /** The user email (gated by sendDefaultPii) */ From 94af18000941971232b1a3d36002700c63698fde Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Nov 2025 11:33:25 +0100 Subject: [PATCH 22/58] rip span kind --- packages/core/src/tracing/sentrySpan.ts | 1 - packages/core/src/types-hoist/span.ts | 1 - packages/core/src/utils/spanUtils.ts | 2 -- 3 files changed, 4 deletions(-) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8e48baa10281..0a4ad47bc9c5 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -264,7 +264,6 @@ export class SentrySpan implements Span { // 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), - kind: 'internal', // TODO: This has to be inferred from attributes SentrySpans. `internal` is the default. status: getV2StatusMessage(this._status), attributes: getV2Attributes(this._attributes), links: getV2SpanLinks(this._links), diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index bdc1fb5477d8..0295f21d19dd 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -43,7 +43,6 @@ export interface SpanV2JSON { start_timestamp: number; end_timestamp: number; status: 'ok' | 'error'; - kind: 'server' | 'client' | 'internal' | 'consumer' | 'producer'; is_segment: boolean; attributes?: SerializedAttributes; links?: SpanLinkJSON[]; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 5c26e3bb0a2d..7fbe4ac695e1 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -241,7 +241,6 @@ export function spanToV2JSON(span: Span): SpanV2JSON { start_timestamp: spanTimeInputToSeconds(startTime), end_timestamp: spanTimeInputToSeconds(endTime), is_segment: span === INTERNAL_getSegmentSpan(span), - kind: 'internal', // TODO: Figure out how to get this from the OTel span as it's not publicly exposed status: getV2StatusMessage(status), attributes: getV2Attributes(attributes), links: getV2SpanLinks(links), @@ -257,7 +256,6 @@ export function spanToV2JSON(span: Span): SpanV2JSON { name: '', end_timestamp: 0, status: 'ok', - kind: 'internal', is_segment: span === INTERNAL_getSegmentSpan(span), }; } From 736eaa277bf8ba9873d7526a3cb3c8be49abed5f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Nov 2025 14:32:28 +0100 Subject: [PATCH 23/58] restart ci From b4618ae12b1bcafb39227fe43d87d6d1f14cecf2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 15:34:00 +0100 Subject: [PATCH 24/58] does this fix size limit? --- .size-limit.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 55e2e6ec7ed3..57002e19108b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -47,13 +47,13 @@ module.exports = [ gzip: true, limit: '48 KB', }, - { - name: '@sentry/browser (incl. Tracing with Span Streaming)', - path: 'packages/browser/build/npm/esm/index.js', - import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), - gzip: true, - limit: '44 KB', - }, + // { + // name: '@sentry/browser (incl. Tracing Span-First)', + // path: 'packages/browser/build/npm/esm/index.js', + // import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), + // gzip: true, + // limit: '44 KB', + // }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/prod/index.js', From 6a0f7fae9f0026c4e740562f76fc74592a138eba Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 15:45:27 +0100 Subject: [PATCH 25/58] kk limits fixed but raise limits :( --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 57002e19108b..c27588005023 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -170,7 +170,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '42.5 KB', + limit: '43 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -220,7 +220,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '46 KB', + limit: '47 KB', }, // SvelteKit SDK (ESM) { From 1bbadd9ebc9a9ab8c893e3b86a937ca5c1e7c48b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 16:07:34 +0100 Subject: [PATCH 26/58] size limit once more --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index c27588005023..a8742b66c5bd 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -89,7 +89,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '85 KB', + limit: '86 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', From 2a7941e2927e982cd1051d4e278335205da0664f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Nov 2025 13:52:11 +0100 Subject: [PATCH 27/58] s/user.username/user.name --- packages/core/src/semanticAttributes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 019f5ae5e020..ac7bc2c3b188 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -96,7 +96,7 @@ 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.username'; +export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; /** 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 */ From df26056caec1b1b8bcae4e7e73fb6011a6bc416d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Nov 2025 14:57:45 +0100 Subject: [PATCH 28/58] one more limit bump --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index a8742b66c5bd..6b6dbd6f0b9b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -134,7 +134,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '44 KB', + limit: '45 KB', }, // Vue SDK (ESM) { From 741916a2919f372afd75b54fd2195ec28e7c1819 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Nov 2025 14:39:49 +0100 Subject: [PATCH 29/58] rewrite to `captureSpan` --- .../browser/src/integrations/spanstreaming.ts | 133 +++--------------- packages/core/src/client.ts | 23 ++- packages/core/src/index.ts | 1 + packages/core/src/tracing/sentrySpan.ts | 3 +- packages/core/src/tracing/trace.ts | 1 + packages/core/src/types-hoist/options.ts | 1 + packages/opentelemetry/src/spanProcessor.ts | 1 + 7 files changed, 41 insertions(+), 122 deletions(-) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 2dba8e8d0899..a3d0d0d326e0 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,27 +1,12 @@ -import type { Client, IntegrationFn, Scope, ScopeData, Span, SpanAttributes, SpanV2JSON } from '@sentry/core'; +import type { Client, IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; import { + captureSpan, createSpanV2Envelope, debug, defineIntegration, - getCapturedScopesOnSpan, getDynamicSamplingContextFromSpan, - getGlobalScope, INTERNAL_getSegmentSpan, isV2BeforeSendSpanCallback, - mergeScopeData, - reparentChildSpans, - SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, - 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_USER_EMAIL, - SEMANTIC_ATTRIBUTE_USER_ID, - SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, - SEMANTIC_ATTRIBUTE_USER_USERNAME, - shouldIgnoreSpan, showSpanDropWarning, spanToV2JSON, } from '@sentry/core'; @@ -72,7 +57,7 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia return; } - client.on('spanEnd', span => { + client.on('enqueueSpan', span => { const spanTreeMapKey = getSpanTreeMapKey(span); const spanBuffer = spanTreeMap.get(spanTreeMapKey); if (spanBuffer) { @@ -82,10 +67,14 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia } }); + client.on('afterSpanEnd', span => { + captureSpan(span, client); + }); + // For now, we send all spans on local segment (root) span end. // TODO: This will change once we have more concrete ideas about a universal SDK data buffer. - client.on('segmentSpanEnd', segmentSpan => { - processAndSendSpans(segmentSpan, { + client.on('afterSegmentSpanEnd', segmentSpan => { + sendSegment(segmentSpan, { spanTreeMap: spanTreeMap, client, batchLimit: options.batchLimit, @@ -110,7 +99,7 @@ function getSpanTreeMapKey(span: Span): string { return `${span.spanContext().traceId}-${INTERNAL_getSegmentSpan(span).spanContext().spanId}`; } -function processAndSendSpans( +function sendSegment( segmentSpan: Span, { client, spanTreeMap, batchLimit, beforeSendSpan }: SpanProcessingOptions, ): void { @@ -123,54 +112,17 @@ function processAndSendSpans( return; } - const segmentSpanJson = spanToV2JSON(segmentSpan); - - for (const span of spansOfTrace) { - applyCommonSpanAttributes(span, segmentSpanJson, client); - } - - const { ignoreSpans } = client.getOptions(); - - // 1. Check if the entire span tree is ignored by ignoreSpans - if (ignoreSpans?.length && shouldIgnoreSpan(segmentSpanJson, ignoreSpans)) { - client.recordDroppedEvent('before_send', 'span', spansOfTrace.size); - spanTreeMap.delete(spanTreeMapKey); - return; - } - - const serializedSpans = Array.from(spansOfTrace ?? []).map(s => { - const serialized = spanToV2JSON(s); - // remove internal span attributes we don't need to send. - delete serialized.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; - return serialized; - }); - - const processedSpans = []; - let ignoredSpanCount = 0; - - for (const span of serializedSpans) { - // 2. Check if child spans should be ignored - const isChildSpan = span.span_id !== segmentSpan.spanContext().spanId; - if (ignoreSpans?.length && isChildSpan && shouldIgnoreSpan(span, ignoreSpans)) { - reparentChildSpans(serializedSpans, span); - ignoredSpanCount++; - // drop this span by not adding it to the processedSpans array - continue; + const finalSpans = Array.from(spansOfTrace).map(span => { + const spanJson = spanToV2JSON(span); + if (beforeSendSpan) { + return applyBeforeSendSpanCallback(spanJson, beforeSendSpan); } - - // 3. Apply beforeSendSpan callback - // TODO: validate beforeSendSpan result/pass in a copy and merge afterwards - const processedSpan = beforeSendSpan ? applyBeforeSendSpanCallback(span, beforeSendSpan) : span; - processedSpans.push(processedSpan); - } - - if (ignoredSpanCount) { - client.recordDroppedEvent('before_send', 'span', ignoredSpanCount); - } + return spanJson; + }); const batches: SpanV2JSON[][] = []; - for (let i = 0; i < processedSpans.length; i += batchLimit) { - batches.push(processedSpans.slice(i, i + batchLimit)); + for (let i = 0; i < finalSpans.length; i += batchLimit) { + batches.push(finalSpans.slice(i, i + batchLimit)); } DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batch${batches.length === 1 ? '' : 'es'}`); @@ -190,35 +142,6 @@ function processAndSendSpans( spanTreeMap.delete(spanTreeMapKey); } -function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void { - const sdk = client.getSdkMetadata(); - const { release, environment, sendDefaultPii } = client.getOptions(); - - const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); - - const originalAttributeKeys = Object.keys(spanToV2JSON(span).attributes ?? {}); - - const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope); - - // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) - setAttributesIfNotPresent(span, originalAttributeKeys, { - [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]: finalScopeData.user?.id, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: finalScopeData.user?.email, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: finalScopeData.user?.ip_address ?? undefined, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: finalScopeData.user?.username, - } - : {}), - }); -} - function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON { const modifedSpan = beforeSendSpan(span); if (!modifedSpan) { @@ -227,23 +150,3 @@ function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: Sp } return modifedSpan; } - -function setAttributesIfNotPresent(span: Span, originalAttributeKeys: string[], newAttributes: SpanAttributes): void { - Object.keys(newAttributes).forEach(key => { - if (!originalAttributeKeys.includes(key)) { - span.setAttribute(key, newAttributes[key]); - } - }); -} - -// TODO: Extract this to a helper in core. It's used in multiple places. -function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | undefined): ScopeData { - const finalScopeData = getGlobalScope().getScopeData(); - if (isolationScope) { - mergeScopeData(finalScopeData, isolationScope.getScopeData()); - } - if (scope) { - mergeScopeData(finalScopeData, scope.getScopeData()); - } - return finalScopeData; -} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 2ef118d0b5a2..b646bf0d4fcb 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -608,13 +608,19 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + // Hooks reserved for Span-First span processing: /** * Register a callback for after a span is ended. - * NOTE: The span cannot be mutated anymore in this callback. - * Receives the span as argument. - * @returns {() => void} A function that, when executed, removes the registered callback. */ - public on(hook: 'segmentSpanEnd', callback: (span: Span) => void): () => void; + public on(hook: 'afterSpanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for after a segment span is ended. + */ + public on(hook: 'afterSegmentSpanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for when the span is ready to be enqueued into the span buffer. + */ + public on(hook: 'enqueueSpan', callback: (span: Span) => void): () => void; /** * Register a callback for when an idle span is allowed to auto-finish. @@ -888,8 +894,13 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; - /** Fire a hook whenever a segment span ends. */ - public emit(hook: 'segmentSpanEnd', span: Span): void; + // Hooks reserved for Span-First span processing: + /** Fire a hook after the `spanEnd` hook */ + public emit(hook: 'afterSpanEnd', span: Span): void; + /** Fire a hook after the `segmentSpanEnd` hook is fired. */ + public emit(hook: 'afterSegmentSpanEnd', span: Span): void; + /** Fire a hook after a span ready to be enqueued into the span buffer. */ + public emit(hook: 'enqueueSpan', span: Span): void; /** * Fire a hook indicating that an idle span is allowed to auto finish. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df22511d73db..3e9cb6165e44 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,6 +90,7 @@ export { spanToV2JSON, showSpanDropWarning, } from './utils/spanUtils'; +export { captureSpan } from './spans/captureSpan'; export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 0a4ad47bc9c5..574ba9ab2478 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -316,6 +316,7 @@ export class SentrySpan implements Span { const client = getClient(); if (client) { client.emit('spanEnd', this); + client.emit('afterSpanEnd', this); } // A segment span is basically the root span of a local span tree. @@ -341,7 +342,7 @@ export class SentrySpan implements Span { return; } else if (client?.getOptions().traceLifecycle === 'stream') { // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans - client?.emit('segmentSpanEnd', this); + client?.emit('afterSegmentSpanEnd', this); return; } diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index b147bb92fa63..2c45275eef0b 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -491,6 +491,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/options.ts b/packages/core/src/types-hoist/options.ts index 2c0bdec60681..586b938990dd 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -1,3 +1,4 @@ +import { RawAttributes } from '../attributes'; import type { CaptureContext } from '../scope'; import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; import type { ErrorEvent, EventHint, TransactionEvent } from './event'; diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index 3430456caaee..07d3c92269ae 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -56,6 +56,7 @@ function onSpanEnd(span: Span): void { const client = getClient(); client?.emit('spanEnd', span); + client?.emit('afterSpanEnd', span); } /** From 00c0d66a2ed9717789da1e41f116a5d8d6ff4946 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Nov 2025 16:18:09 +0100 Subject: [PATCH 30/58] capturespan --- packages/core/src/spans/captureSpan.ts | 137 +++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/core/src/spans/captureSpan.ts diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts new file mode 100644 index 000000000000..6f43588f6cb1 --- /dev/null +++ b/packages/core/src/spans/captureSpan.ts @@ -0,0 +1,137 @@ +import { type RawAttributes, isAttributeObject } from '../attributes'; +import type { Client } from '../client'; +import { getClient, getGlobalScope } from '../currentScopes'; +import { DEBUG_BUILD } from '../debug-build'; +import type { Scope, 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_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, +} from '../semanticAttributes'; +import { getCapturedScopesOnSpan } from '../tracing/utils'; +import type { Span, SpanV2JSON } from '../types-hoist/span'; +import { mergeScopeData } from '../utils/applyScopeDataToEvent'; +import { debug } from '../utils/debug-logger'; +import { INTERNAL_getSegmentSpan, spanToV2JSON } from '../utils/spanUtils'; + +/** + * Captures a span and returns it to the caller, to be enqueued for sending. + */ +export function captureSpan(span: Span, client = getClient()): void { + if (!client) { + DEBUG_BUILD && debug.warn('No client available to capture span.'); + return; + } + + const segmentSpan = INTERNAL_getSegmentSpan(span); + const serializedSegmentSpan = spanToV2JSON(segmentSpan); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope); + + const originalAttributeKeys = Object.keys(serializedSegmentSpan.attributes ?? {}); + + applyCommonSpanAttributes(span, serializedSegmentSpan, client, finalScopeData, originalAttributeKeys); + + if (span === segmentSpan) { + applyScopeToSegmentSpan(span, finalScopeData, originalAttributeKeys); + } + + // Wondering where we apply the beforeSendSpan callback? + // We apply it directly before sending the span, + // so whenever the buffer this span gets enqueued in is being flushed. + // Why? Because we have to enqueue the span instance itself, not a JSON object. + // We could temporarily convert to JSON here but this means that we'd then again + // have to mutate the `span` instance (doesn't work for every kind of object mutation) + // or construct a fully new span object. The latter is risky because users (or we) could hold + // references to the original span instance. + client.emit('enqueueSpan', span); +} + +function applyScopeToSegmentSpan(segmentSpan: Span, scopeData: ScopeData, originalAttributeKeys: string[]): void { + // TODO: Apply all scope data from auto instrumentation (contexts, request) to segment span + const { attributes } = scopeData; + if (attributes) { + setAttributesIfNotPresent(segmentSpan, originalAttributeKeys, attributes); + } +} + +function applyCommonSpanAttributes( + span: Span, + serializedSegmentSpan: SpanV2JSON, + client: Client, + scopeData: ScopeData, + originalAttributeKeys: string[], +): void { + const sdk = client.getSdkMetadata(); + const { release, environment, sendDefaultPii } = client.getOptions(); + + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + setAttributesIfNotPresent(span, originalAttributeKeys, { + [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 ?? undefined, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + } + : {}), + }); +} + +// TODO: Extract this to a helper in core. It's used in multiple places. +function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | undefined): ScopeData { + const finalScopeData = getGlobalScope().getScopeData(); + if (isolationScope) { + mergeScopeData(finalScopeData, isolationScope.getScopeData()); + } + if (scope) { + mergeScopeData(finalScopeData, scope.getScopeData()); + } + return finalScopeData; +} + +function setAttributesIfNotPresent( + span: Span, + originalAttributeKeys: string[], + newAttributes: RawAttributes>, +): void { + Object.keys(newAttributes).forEach(key => { + if (!originalAttributeKeys.includes(key)) { + setAttributeOnSpanWithMaybeUnit(span, key, newAttributes[key]); + } + }); +} + +function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attributeValue: unknown): void { + if (isAttributeObject(attributeValue)) { + const { value, unit } = attributeValue; + + if (isSupportedAttributeType(value)) { + span.setAttribute(attributeKey, value); + } + + if (unit) { + span.setAttribute(`${attributeKey}.unit`, unit); + } + } else if (isSupportedAttributeType(attributeValue)) { + span.setAttribute(attributeKey, attributeValue); + } +} + +function isSupportedAttributeType(value: unknown): value is Parameters[1] { + return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value); +} From b727599c73ad1f3a74273a18b0a2d901bad6ff7e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 1 Dec 2025 15:48:18 +0100 Subject: [PATCH 31/58] add integration test for pageload span --- .../utils/helpers.ts | 39 +++++++++++++++++++ .../tracing/meta-tags-twp-errors/test.ts | 13 +++++++ 2 files changed, 52 insertions(+) diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index dd75d2f6ee86..a4b2af126b5c 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -9,6 +9,7 @@ import type { EventEnvelope, EventEnvelopeHeaders, SessionContext, + SpanV2Envelope, TransactionEvent, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; @@ -258,6 +259,44 @@ export function waitForTransactionRequest( }); } +/** + * Wait for a span v2 envelope + */ +export async function waitForSpanV2Envelope( + page: Page, + callback?: (spanEnvelope: SpanV2Envelope) => 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); +} + export function waitForClientReportRequest(page: Page, callback?: (report: ClientReport) => boolean): Promise { return page.waitForRequest(req => { const postData = req.postData(); diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts index d58f35b02972..c1859a3a67b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { run } from 'node:test'; describe('errors in TwP mode have same trace in trace context and getTraceData()', () => { afterAll(() => { @@ -8,11 +9,16 @@ describe('errors in TwP mode have same trace in trace context and getTraceData() // In a request handler, the spanId is consistent inside of the request test('in incoming request', async () => { + let firstTraceId: string | undefined; + const runner = createRunner(__dirname, 'server.js') .expect({ event: event => { const { contexts } = event; const { trace_id, span_id } = contexts?.trace || {}; + if (!firstTraceId) { + firstTraceId = trace_id; + } expect(trace_id).toMatch(/^[a-f\d]{32}$/); expect(span_id).toMatch(/^[a-f\d]{16}$/); @@ -28,8 +34,15 @@ describe('errors in TwP mode have same trace in trace context and getTraceData() expect(traceData.metaTags).not.toContain('sentry-sampled='); }, }) + .expect({ + event: event => { + expect(event.contexts?.trace?.trace_id).toBeDefined(); + expect(event.contexts?.trace?.trace_id).toBe(firstTraceId); + }, + }) .start(); runner.makeRequest('get', '/test'); + runner.makeRequest('get', '/test'); await runner.completed(); }); From 216648c25103af1a27554d79ea5a6ebdaf0e53b3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 1 Dec 2025 17:07:49 +0100 Subject: [PATCH 32/58] more integration tests --- .../span-first/backgroundtab-pageload/init.js | 12 ++ .../backgroundtab-pageload/subject.js | 8 ++ .../backgroundtab-pageload/template.html | 9 ++ .../span-first/backgroundtab-pageload/test.ts | 22 ++++ .../suites/span-first/error/init.js | 13 ++ .../suites/span-first/error/test.ts | 50 ++++++++ .../suites/span-first/pageload/init.js | 12 ++ .../suites/span-first/pageload/test.ts | 118 ++++++++++++++++++ .../utils/helpers.ts | 41 +----- .../utils/spanFirstUtils.ts | 62 +++++++++ 10 files changed, 307 insertions(+), 40 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/init.js create mode 100644 dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/template.html create mode 100644 dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/span-first/error/init.js create mode 100644 dev-packages/browser-integration-tests/suites/span-first/error/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/span-first/pageload/init.js create mode 100644 dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts create mode 100644 dev-packages/browser-integration-tests/utils/spanFirstUtils.ts diff --git a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/init.js b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/init.js new file mode 100644 index 000000000000..5541015d7585 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/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', + traceLifecycle: 'stream', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + sendDefaultPii: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/subject.js b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/subject.js new file mode 100644 index 000000000000..b657f38ac009 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/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/span-first/backgroundtab-pageload/template.html b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/template.html new file mode 100644 index 000000000000..31cfc73ec3c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts new file mode 100644 index 000000000000..368820e754fb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts @@ -0,0 +1,22 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; + +sentryTest('ends pageload span when the page goes to background', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spanPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); + + await page.goto(url); + await page.locator('#go-background').click(); + + const pageloadSpan = (await spanPromise).find(span => getSpanOp(span) === 'pageload'); + + expect(pageloadSpan?.status).toBe('error'); // a cancelled span previously mapped to status error with message cancelled. + expect(pageloadSpan?.attributes?.['sentry.op']?.value).toBe('pageload'); + expect(pageloadSpan?.attributes?.['sentry.cancellation_reason']?.value).toBe('document.hidden'); +}); diff --git a/dev-packages/browser-integration-tests/suites/span-first/error/init.js b/dev-packages/browser-integration-tests/suites/span-first/error/init.js new file mode 100644 index 000000000000..853d9ec8f605 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/error/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: 'stream', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + sendDefaultPii: true, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/span-first/error/test.ts b/dev-packages/browser-integration-tests/suites/span-first/error/test.ts new file mode 100644 index 000000000000..682cece57172 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/error/test.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + envelopeRequestParser, + runScriptInSandbox, + shouldSkipTracingTest, + waitForErrorRequest, +} from '../../../utils/helpers'; +import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; + +sentryTest( + 'puts the pageload span name onto an error event caught during pageload', + async ({ getLocalTestUrl, page, browserName }) => { + if (browserName === 'webkit') { + // This test fails on Webkit as errors thrown from `runScriptInSandbox` are Script Errors and skipped by Sentry + sentryTest.skip(); + } + + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const errorEventPromise = waitForErrorRequest(page); + const spanPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); + + await page.goto(url); + + await runScriptInSandbox(page, { + content: ` + throw new Error('Error during pageload'); + `, + }); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + const pageloadSpan = (await spanPromise).find(span => getSpanOp(span) === 'pageload'); + + expect(pageloadSpan?.attributes?.['sentry.op']?.value).toEqual('pageload'); + expect(errorEvent.exception?.values?.[0]).toBeDefined(); + + expect(pageloadSpan?.name).toEqual('/index.html'); + + expect(pageloadSpan?.status).toBe('error'); + expect(pageloadSpan?.attributes?.['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); + + expect(errorEvent.transaction).toEqual(pageloadSpan?.name); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/span-first/pageload/init.js b/dev-packages/browser-integration-tests/suites/span-first/pageload/init.js new file mode 100644 index 000000000000..5541015d7585 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/pageload/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', + traceLifecycle: 'stream', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + sendDefaultPii: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts new file mode 100644 index 000000000000..1092f53a8698 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts @@ -0,0 +1,118 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { getSpanOp, waitForSpanV2Envelope } from '../../../utils/spanFirstUtils'; + +sentryTest('sends a span v2 envelope for the pageload', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const spanEnvelopePromise = waitForSpanV2Envelope(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const spanEnvelope = await spanEnvelopePromise; + + const envelopeHeaders = spanEnvelope[0]; + + const envelopeItem0 = spanEnvelope[1][0]; + const envelopeItemHeader = envelopeItem0[0]; + const envelopeItem = envelopeItem0[1]; + + expect(envelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + sampled: 'true', + sample_rand: expect.any(String), + sample_rate: '1', + }, + sdk: { + name: 'sentry.javascript.browser', + packages: [ + { + name: 'npm:@sentry/browser', + version: expect.any(String), + }, + ], + version: expect.any(String), + settings: { + infer_ip: 'auto', + }, + }, + }); + + expect(envelopeItemHeader).toEqual({ + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: expect.any(Number), + type: 'span', + }); + + // test the shape of the item first, then the content + expect(envelopeItem).toEqual({ + items: expect.any(Array), + }); + + expect(envelopeItem.items.length).toBe(envelopeItemHeader.item_count); + + const pageloadSpan = envelopeItem.items.find(item => getSpanOp(item) === 'pageload'); + + expect(pageloadSpan).toBeDefined(); + + expect(pageloadSpan).toEqual({ + attributes: expect.objectContaining({ + 'performance.activationStart': { + type: 'integer', + value: 0, + }, + 'performance.timeOrigin': { + type: 'double', + value: expect.any(Number), + }, + 'sentry.op': { + type: 'string', + value: 'pageload', + }, + 'sentry.origin': { + type: 'string', + value: 'auto.pageload.browser', + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: pageloadSpan?.span_id, // pageload is always the segment + }, + 'sentry.segment.name': { + type: 'string', + value: '/index.html', + }, + 'sentry.source': { + type: 'string', + value: 'url', + }, + }), + trace_id: expect.stringMatching(/^[a-f\d]{32}$/), + span_id: expect.stringMatching(/^[a-f\d]{16}$/), + name: '/index.html', + status: 'ok', + is_segment: true, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + }); +}); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index a4b2af126b5c..0495a539ff53 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -9,7 +9,6 @@ import type { EventEnvelope, EventEnvelopeHeaders, SessionContext, - SpanV2Envelope, TransactionEvent, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; @@ -63,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() || ''; @@ -259,44 +258,6 @@ export function waitForTransactionRequest( }); } -/** - * Wait for a span v2 envelope - */ -export async function waitForSpanV2Envelope( - page: Page, - callback?: (spanEnvelope: SpanV2Envelope) => 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); -} - export function waitForClientReportRequest(page: Page, callback?: (report: ClientReport) => boolean): Promise { return page.waitForRequest(req => { const postData = req.postData(); diff --git a/dev-packages/browser-integration-tests/utils/spanFirstUtils.ts b/dev-packages/browser-integration-tests/utils/spanFirstUtils.ts new file mode 100644 index 000000000000..212355f5e780 --- /dev/null +++ b/dev-packages/browser-integration-tests/utils/spanFirstUtils.ts @@ -0,0 +1,62 @@ +import type { Page } from '@playwright/test'; +import type { SpanV2Envelope, SpanV2JSON } from '@sentry/core'; +import { properFullEnvelopeParser } from './helpers'; + +/** + * Wait for a span v2 envelope + */ +export async function waitForSpanV2Envelope( + page: Page, + callback?: (spanEnvelope: SpanV2Envelope) => 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. + * (We might need a more sophisticated helper that waits for N envelopes and buckets by traceId) + * For now, this should do. + * @param page + * @param callback - Callback being called with all spans + */ +export async function waitForV2Spans(page: Page, callback?: (spans: SpanV2JSON[]) => boolean): Promise { + const spanEnvelope = await waitForSpanV2Envelope(page, envelope => { + if (callback) { + return callback(envelope[1][0][1].items); + } + return true; + }); + return spanEnvelope[1][0][1].items; +} + +export function getSpanOp(span: SpanV2JSON): string | undefined { + return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes?.['sentry.op']?.value : undefined; +} From 5596acffb6283efe5e314fa0e677bf0f957149fd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 1 Dec 2025 17:14:49 +0100 Subject: [PATCH 33/58] span links test --- .../suites/span-first/init.js | 12 +++ .../suites/span-first/linked-traces/test.ts | 100 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/span-first/init.js create mode 100644 dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts diff --git a/dev-packages/browser-integration-tests/suites/span-first/init.js b/dev-packages/browser-integration-tests/suites/span-first/init.js new file mode 100644 index 000000000000..5541015d7585 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/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', + traceLifecycle: 'stream', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + sendDefaultPii: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts new file mode 100644 index 000000000000..5fd9df3dbae1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts @@ -0,0 +1,100 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers'; +import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; + +sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpan = await sentryTest.step('Initial pageload', async () => { + const pageloadSpanPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); + await page.goto(url); + return (await pageloadSpanPromise).find(span => getSpanOp(span) === 'pageload'); + }); + + const navigation1Span = await sentryTest.step('First navigation', async () => { + const navigation1SpanPromise = waitForV2Spans( + page, + spans => !!spans.find(span => getSpanOp(span) === 'navigation'), + ); + await page.goto(`${url}#foo`); + return (await navigation1SpanPromise).find(span => getSpanOp(span) === 'navigation'); + }); + + const navigation2Span = await sentryTest.step('Second navigation', async () => { + const navigation2SpanPromise = waitForV2Spans( + page, + spans => !!spans.find(span => getSpanOp(span) === 'navigation'), + ); + await page.goto(`${url}#bar`); + return (await navigation2SpanPromise).find(span => getSpanOp(span) === 'navigation'); + }); + + 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]: { value: 'previous_trace', type: 'string' }, + }, + }, + ]); + + expect(navigation1Span?.attributes).toMatchObject({ + 'sentry.previous_trace': { 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]: { value: 'previous_trace', type: 'string' }, + }, + }, + ]); + + expect(navigation2Span?.attributes).toMatchObject({ + 'sentry.previous_trace': { 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 }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await sentryTest.step('First pageload', async () => { + const pageloadRequestPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); + await page.goto(url); + return (await pageloadRequestPromise).find(span => getSpanOp(span) === 'pageload'); + }); + + await sentryTest.step('Second pageload', async () => { + const pageload2RequestPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); + await page.reload(); + const pageload2Span = (await pageload2RequestPromise).find(span => getSpanOp(span) === 'pageload'); + + expect(pageload2Span?.trace_id).toBeDefined(); + expect(pageload2Span?.links).toBeUndefined(); + }); +}); From 2c57e0b78328909736815a0066d2f1d0b6f6028e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 2 Dec 2025 10:37:57 +0100 Subject: [PATCH 34/58] set web vitals as attributes in span-first --- .../suites/span-first/linked-traces/test.ts | 2 +- packages/browser-utils/src/metrics/browserMetrics.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts index 5fd9df3dbae1..4709fd6ae81c 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => { diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 3c3dee074cb5..13ba874ff055 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -3,6 +3,7 @@ import type { Client, Measurements, Span, SpanAttributes, SpanAttributeValue, St import { browserPerformanceTimeOrigin, getActiveSpan, + getClient, getComponentName, htmlTreeAsString, isPrimitive, @@ -402,7 +403,12 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries delete _measurements.lcp; } + const isSpanFirst = getClient()?.getOptions().traceLifecycle === 'stream'; Object.entries(_measurements).forEach(([measurementName, measurement]) => { + if (isSpanFirst) { + span.setAttribute(`ui.web_vital.${measurementName}`, measurement.value); + return; + } setMeasurement(measurementName, measurement.value, measurement.unit); }); From 8a0d6627d3a9bc4d111399229cbf2b20f495b688 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 2 Dec 2025 16:10:05 +0100 Subject: [PATCH 35/58] rewrite httpContext integration to use processSpan client hook --- .../suites/span-first/pageload/test.ts | 12 +++ .../web-vitals/web-vitals-ttfb/init.js | 13 +++ .../web-vitals/web-vitals-ttfb/template.html | 9 ++ .../web-vitals/web-vitals-ttfb/test.ts | 33 +++++++ .../browser/src/integrations/httpcontext.ts | 37 +++++++- packages/core/src/client.ts | 8 +- packages/core/src/index.ts | 1 + packages/core/src/spans/captureSpan.ts | 92 ++++++++----------- packages/core/src/spans/spanFirstUtils.ts | 39 ++++++++ packages/core/src/utils/spanUtils.ts | 6 +- 10 files changed, 185 insertions(+), 65 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/init.js create mode 100644 dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/template.html create mode 100644 dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts create mode 100644 packages/core/src/spans/spanFirstUtils.ts diff --git a/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts index 1092f53a8698..e488d96bc9e6 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts @@ -106,6 +106,18 @@ sentryTest('sends a span v2 envelope for the pageload', async ({ getLocalTestUrl type: 'string', value: 'url', }, + 'sentry.idle_span_finish_reason': { + type: 'string', + value: 'idleTimeout', + }, + 'url.full': { + type: 'string', + value: 'http://sentry-test.io/index.html', + }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, }), trace_id: expect.stringMatching(/^[a-f\d]{32}$/), span_id: expect.stringMatching(/^[a-f\d]{16}$/), diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/init.js b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/init.js new file mode 100644 index 000000000000..853d9ec8f605 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: 'stream', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + sendDefaultPii: true, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/template.html b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/template.html new file mode 100644 index 000000000000..e98eee38c4e3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/template.html @@ -0,0 +1,9 @@ + + + + + + +
Rendered
+ + diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts new file mode 100644 index 000000000000..a54f4c1bdb24 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts @@ -0,0 +1,33 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForV2Spans } from '../../../../utils/spanFirstUtils'; + +sentryTest('captures TTFB web vital', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + const pageloadSpansPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const pageloadSpan = (await pageloadSpansPromise).find(span => getSpanOp(span) === 'pageload'); + + expect(pageloadSpan).toBeDefined(); + + // If responseStart === 0, ttfb is not reported + // This seems to happen somewhat randomly, so we just ignore this in that case + const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;"); + if (responseStart !== 0) { + expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb']).toEqual({ + type: expect.stringMatching(/^double$/), + value: expect.any(Number), + }); + } + + expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb.requestTime']).toEqual({ + type: expect.stringMatching(/^integer|double$/), + value: expect.any(Number), + }); +}); diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 254e867301af..35e0c7553236 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,4 +1,9 @@ -import { defineIntegration } from '@sentry/core'; +import { + defineIntegration, + httpHeadersToSpanAttributes, + safeSetSpanAttributes, + SEMANTIC_ATTRIBUTE_URL_FULL, +} from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; /** @@ -6,13 +11,37 @@ import { getHttpRequestData, WINDOW } from '../helpers'; * attaches them to the event. */ export const httpContextIntegration = defineIntegration(() => { + const inBrowserEnvironment = WINDOW.navigator || WINDOW.location || WINDOW.document; + return { name: 'HttpContext', - // TODO (span-streaming): probably fine to omit this in favour of us globally - // already adding request context data but should double-check this + setup(client) { + if (!inBrowserEnvironment) { + return; + } + + if (client.getOptions().traceLifecycle === 'stream') { + client.on('processSpan', (span, { readOnlySpan }) => { + if (readOnlySpan.is_segment) { + const { url, headers } = getHttpRequestData(); + + const attributeHeaders = httpHeadersToSpanAttributes(headers); + + safeSetSpanAttributes( + span, + { + [SEMANTIC_ATTRIBUTE_URL_FULL]: url, + ...attributeHeaders, + }, + readOnlySpan.attributes, + ); + } + }); + } + }, preprocessEvent(event) { // if none of the information we want exists, don't bother - if (!WINDOW.navigator && !WINDOW.location && !WINDOW.document) { + if (!inBrowserEnvironment) { return; } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b646bf0d4fcb..fa877c4ac762 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, SpanV2JSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; @@ -621,6 +621,10 @@ export abstract class Client { * Register a callback for when the span is ready to be enqueued into the span buffer. */ public on(hook: 'enqueueSpan', callback: (span: Span) => void): () => void; + /** + * Register a callback for when a span is processed, to add some attributes to the span. + */ + public on(hook: 'processSpan', callback: (span: Span, hint: { readOnlySpan: SpanV2JSON }) => void): () => void; /** * Register a callback for when an idle span is allowed to auto-finish. @@ -897,6 +901,8 @@ export abstract class Client { // Hooks reserved for Span-First span processing: /** Fire a hook after the `spanEnd` hook */ public emit(hook: 'afterSpanEnd', span: Span): void; + /** Fire a hook after a span is processed, to add some attributes to the span. */ + public emit(hook: 'processSpan', span: Span, hint: { readOnlySpan: SpanV2JSON }): void; /** Fire a hook after the `segmentSpanEnd` hook is fired. */ public emit(hook: 'afterSegmentSpanEnd', span: Span): void; /** Fire a hook after a span ready to be enqueued into the span buffer. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e9cb6165e44..1b7acd5b60e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,6 +91,7 @@ export { showSpanDropWarning, } from './utils/spanUtils'; export { captureSpan } from './spans/captureSpan'; +export { safeSetSpanAttributes } from './spans/spanFirstUtils'; export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index 6f43588f6cb1..3ea34c7b53f8 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -1,4 +1,3 @@ -import { type RawAttributes, isAttributeObject } from '../attributes'; import type { Client } from '../client'; import { getClient, getGlobalScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; @@ -16,10 +15,12 @@ import { SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../semanticAttributes'; import { getCapturedScopesOnSpan } from '../tracing/utils'; +import type { SerializedAttributes } from '../types-hoist/attributes'; import type { Span, SpanV2JSON } from '../types-hoist/span'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { debug } from '../utils/debug-logger'; import { INTERNAL_getSegmentSpan, spanToV2JSON } from '../utils/spanUtils'; +import { safeSetSpanAttributes } from './spanFirstUtils'; /** * Captures a span and returns it to the caller, to be enqueued for sending. @@ -36,14 +37,19 @@ export function captureSpan(span: Span, client = getClient()): void { const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope); - const originalAttributeKeys = Object.keys(serializedSegmentSpan.attributes ?? {}); + const originalAttributes = serializedSegmentSpan.attributes ?? {}; - applyCommonSpanAttributes(span, serializedSegmentSpan, client, finalScopeData, originalAttributeKeys); + applyCommonSpanAttributes(span, serializedSegmentSpan, client, finalScopeData, originalAttributes); if (span === segmentSpan) { - applyScopeToSegmentSpan(span, finalScopeData, originalAttributeKeys); + applyScopeToSegmentSpan(span, finalScopeData, originalAttributes); } + // Allow integrations to add additional data to span. Pass in a serialized + // span to avoid having to potentially serialize the span in every integration + // (for improved performance). + client.emit('processSpan', span, { readOnlySpan: spanToV2JSON(span) }); + // Wondering where we apply the beforeSendSpan callback? // We apply it directly before sending the span, // so whenever the buffer this span gets enqueued in is being flushed. @@ -55,11 +61,15 @@ export function captureSpan(span: Span, client = getClient()): void { client.emit('enqueueSpan', span); } -function applyScopeToSegmentSpan(segmentSpan: Span, scopeData: ScopeData, originalAttributeKeys: string[]): void { +function applyScopeToSegmentSpan( + segmentSpan: Span, + scopeData: ScopeData, + originalAttributes: SerializedAttributes, +): void { // TODO: Apply all scope data from auto instrumentation (contexts, request) to segment span const { attributes } = scopeData; if (attributes) { - setAttributesIfNotPresent(segmentSpan, originalAttributeKeys, attributes); + safeSetSpanAttributes(segmentSpan, attributes, originalAttributes); } } @@ -68,28 +78,32 @@ function applyCommonSpanAttributes( serializedSegmentSpan: SpanV2JSON, client: Client, scopeData: ScopeData, - originalAttributeKeys: string[], + originalAttributes: SerializedAttributes, ): void { const sdk = client.getSdkMetadata(); const { release, environment, sendDefaultPii } = client.getOptions(); // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) - setAttributesIfNotPresent(span, originalAttributeKeys, { - [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 ?? undefined, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, - } - : {}), - }); + safeSetSpanAttributes( + span, + { + [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 ?? undefined, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + } + : {}), + }, + originalAttributes, + ); } // TODO: Extract this to a helper in core. It's used in multiple places. @@ -103,35 +117,3 @@ function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | und } return finalScopeData; } - -function setAttributesIfNotPresent( - span: Span, - originalAttributeKeys: string[], - newAttributes: RawAttributes>, -): void { - Object.keys(newAttributes).forEach(key => { - if (!originalAttributeKeys.includes(key)) { - setAttributeOnSpanWithMaybeUnit(span, key, newAttributes[key]); - } - }); -} - -function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attributeValue: unknown): void { - if (isAttributeObject(attributeValue)) { - const { value, unit } = attributeValue; - - if (isSupportedAttributeType(value)) { - span.setAttribute(attributeKey, value); - } - - if (unit) { - span.setAttribute(`${attributeKey}.unit`, unit); - } - } else if (isSupportedAttributeType(attributeValue)) { - span.setAttribute(attributeKey, attributeValue); - } -} - -function isSupportedAttributeType(value: unknown): value is Parameters[1] { - return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value); -} diff --git a/packages/core/src/spans/spanFirstUtils.ts b/packages/core/src/spans/spanFirstUtils.ts new file mode 100644 index 000000000000..10a5b2b3439e --- /dev/null +++ b/packages/core/src/spans/spanFirstUtils.ts @@ -0,0 +1,39 @@ +import type { RawAttributes } from '../attributes'; +import { isAttributeObject } from '../attributes'; +import type { SerializedAttributes } from '../types-hoist/attributes'; +import type { Span } from '../types-hoist/span'; + +/** + * Only set a span attribute if it is not already set. + */ +export function safeSetSpanAttributes( + span: Span, + newAttributes: RawAttributes>, + originalAttributeKeys: SerializedAttributes | undefined, +): void { + Object.keys(newAttributes).forEach(key => { + if (!originalAttributeKeys?.[key]) { + setAttributeOnSpanWithMaybeUnit(span, key, newAttributes[key]); + } + }); +} + +function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attributeValue: unknown): void { + if (isAttributeObject(attributeValue)) { + const { value, unit } = attributeValue; + + if (isSupportedAttributeType(value)) { + span.setAttribute(attributeKey, value); + } + + if (unit) { + span.setAttribute(`${attributeKey}.unit`, unit); + } + } else if (isSupportedAttributeType(attributeValue)) { + span.setAttribute(attributeKey, attributeValue); + } +} + +function isSupportedAttributeType(value: unknown): value is Parameters[1] { + return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value); +} diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 7fbe4ac695e1..b28b26c37709 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -314,11 +314,7 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef * Convert the various statuses to the ones expected by Sentry ('ok' is default) */ export function getV2StatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { - return !status || - status.code === SPAN_STATUS_UNSET || - (status.code === SPAN_STATUS_ERROR && status.message === 'unknown_error') - ? 'ok' - : 'error'; + return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error'; } /** From 1ac1405945aea1c56925069c10132801ecf76fbd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 4 Dec 2025 10:19:23 +0100 Subject: [PATCH 36/58] minor lint stuff --- packages/core/src/types-hoist/options.ts | 1 - packages/core/src/utils/spanUtils.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 586b938990dd..2c0bdec60681 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -1,4 +1,3 @@ -import { RawAttributes } from '../attributes'; import type { CaptureContext } from '../scope'; import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; import type { ErrorEvent, EventHint, TransactionEvent } from './event'; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index b28b26c37709..6f0977411639 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -8,7 +8,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; -import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; +import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { SerializedAttributes } from '../types-hoist/attributes'; import type { TraceContext } from '../types-hoist/context'; From 40c74d1051756651fcc01bc89c1a1ad098850a4b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 4 Dec 2025 16:36:41 +0100 Subject: [PATCH 37/58] initial StreamingSpanExporter implementation for otel --- packages/core/src/spans/captureSpan.ts | 4 ++- packages/node/src/sdk/initOtel.ts | 1 + packages/opentelemetry/src/spanExporter.ts | 10 ++++-- packages/opentelemetry/src/spanProcessor.ts | 36 +++++++++++++-------- packages/vercel-edge/src/sdk.ts | 1 + 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index 3ea34c7b53f8..1b4d54fe3355 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -25,7 +25,7 @@ import { safeSetSpanAttributes } from './spanFirstUtils'; /** * Captures a span and returns it to the caller, to be enqueued for sending. */ -export function captureSpan(span: Span, client = getClient()): void { +export function captureSpan(span: Span, client = getClient()): Span | void { if (!client) { DEBUG_BUILD && debug.warn('No client available to capture span.'); return; @@ -59,6 +59,8 @@ export function captureSpan(span: Span, client = getClient()): void { // or construct a fully new span object. The latter is risky because users (or we) could hold // references to the original span instance. client.emit('enqueueSpan', span); + + return span; } function applyScopeToSegmentSpan( diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index a0f1951c376b..6492c9dbb101 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -107,6 +107,7 @@ export function setupOtel( spanProcessors: [ new SentrySpanProcessor({ timeout: _clampSpanProcessorTimeout(client.getOptions().maxSpanWaitDuration), + client, }), ...(options.spanProcessors || []), ], diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ea85641387a5..ff471cef7a79 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import { ATTR_HTTP_RESPONSE_STATUS_CODE, SEMATTRS_HTTP_STATUS_CODE } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes, @@ -46,10 +46,16 @@ interface FinishedSpanBucket { spans: Set; } +export interface ISentrySpanExporter { + export(span: ReadableSpan): void; + flush(): void; + clear(): void; +} + /** * A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. */ -export class SentrySpanExporter { +export class SentrySpanExporter implements ISentrySpanExporter { /* * A quick explanation on the buckets: We do bucketing of finished spans for efficiency. This span exporter is * accumulating spans until a root span is encountered and then it flushes all the spans that are descendants of that diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index 07d3c92269ae..956c98a9654e 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -1,8 +1,10 @@ import type { Context } from '@opentelemetry/api'; import { ROOT_CONTEXT, trace } from '@opentelemetry/api'; import type { ReadableSpan, Span, SpanProcessor as SpanProcessorInterface } from '@opentelemetry/sdk-trace-base'; +import type { Client } from '@sentry/core'; import { addChildSpanToSpan, + captureSpan, getClient, getDefaultCurrentScope, getDefaultIsolationScope, @@ -11,7 +13,9 @@ import { setCapturedScopesOnSpan, } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes'; +import type { ISentrySpanExporter } from './spanExporter'; import { SentrySpanExporter } from './spanExporter'; +import { StreamingSpanExporter } from './streamedSpanExporter'; import { getScopesFromContext } from './utils/contextData'; import { setIsSetup } from './utils/setupCheck'; @@ -51,24 +55,22 @@ function onSpanStart(span: Span, parentContext: Context): void { client?.emit('spanStart', span); } -function onSpanEnd(span: Span): void { - logSpanEnd(span); - - const client = getClient(); - client?.emit('spanEnd', span); - client?.emit('afterSpanEnd', span); -} - /** * Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via * the Sentry SDK. */ export class SentrySpanProcessor implements SpanProcessorInterface { - private _exporter: SentrySpanExporter; + private _exporter: ISentrySpanExporter; + private _client: Client | undefined; - public constructor(options?: { timeout?: number }) { + public constructor(options?: { timeout?: number; client?: Client }) { setIsSetup('SentrySpanProcessor'); - this._exporter = new SentrySpanExporter(options); + this._client = options?.client ?? getClient(); + if (this._client?.getOptions().traceLifecycle === 'stream') { + this._exporter = new StreamingSpanExporter(this._client, { flushInterval: options?.timeout }); + } else { + this._exporter = new SentrySpanExporter(options); + } } /** @@ -94,8 +96,16 @@ export class SentrySpanProcessor implements SpanProcessorInterface { /** @inheritDoc */ public onEnd(span: Span & ReadableSpan): void { - onSpanEnd(span); + logSpanEnd(span); + + this._client?.emit('spanEnd', span); - this._exporter.export(span); + if (this._client?.getOptions().traceLifecycle === 'stream') { + // we probably don't need to emit afterSpanEnd here but can call captureSpan directly. + // might need to revisit but let's see. + captureSpan(span, this._client); + } else { + this._exporter.export(span); + } } } diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 5c8387c9bc7a..b09c506f15c2 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -170,6 +170,7 @@ export function setupOtel(client: VercelEdgeClient): void { spanProcessors: [ new SentrySpanProcessor({ timeout: client.getOptions().maxSpanWaitDuration, + client, }), ], }); From 267ba6eeb44a2cf6748360d68ac03d012e5bb7b1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 5 Dec 2025 16:42:20 +0100 Subject: [PATCH 38/58] rewrite pipeline to just always use spanJSonV2 because thanks OTel --- .vscode/settings.json | 3 +- .../browser/src/integrations/httpcontext.ts | 12 ++-- .../browser/src/integrations/spanstreaming.ts | 36 ++++++----- packages/core/src/client.ts | 14 ++--- packages/core/src/index.ts | 3 +- packages/core/src/spans/captureSpan.ts | 62 +++++++++++-------- packages/core/src/spans/spanFirstUtils.ts | 52 +++++++++++++++- packages/node-core/src/index.ts | 1 + packages/node/src/index.ts | 1 + packages/opentelemetry/src/spanExporter.ts | 7 ++- packages/opentelemetry/src/spanProcessor.ts | 10 +-- 11 files changed, 133 insertions(+), 68 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c3515b80ced8..cd7be1cecd9f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,5 +28,6 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "angular.enable-strict-mode-prompt": false } diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 35e0c7553236..7bc1bdbe990b 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,7 +1,7 @@ import { defineIntegration, httpHeadersToSpanAttributes, - safeSetSpanAttributes, + safeSetSpanJSONAttributes, SEMANTIC_ATTRIBUTE_URL_FULL, } from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; @@ -21,19 +21,19 @@ export const httpContextIntegration = defineIntegration(() => { } if (client.getOptions().traceLifecycle === 'stream') { - client.on('processSpan', (span, { readOnlySpan }) => { - if (readOnlySpan.is_segment) { + client.on('processSpan', spanJSON => { + if (spanJSON.is_segment) { const { url, headers } = getHttpRequestData(); const attributeHeaders = httpHeadersToSpanAttributes(headers); - safeSetSpanAttributes( - span, + safeSetSpanJSONAttributes( + spanJSON, { [SEMANTIC_ATTRIBUTE_URL_FULL]: url, ...attributeHeaders, }, - readOnlySpan.attributes, + spanJSON.attributes, ); } }); diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index a3d0d0d326e0..be7b2f328e52 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -1,14 +1,12 @@ -import type { Client, IntegrationFn, Span, SpanV2JSON } from '@sentry/core'; +import type { Client, IntegrationFn, Span, SpanV2JSON, SpanV2JSONWithSegmentRef } from '@sentry/core'; import { captureSpan, createSpanV2Envelope, debug, defineIntegration, getDynamicSamplingContextFromSpan, - INTERNAL_getSegmentSpan, isV2BeforeSendSpanCallback, showSpanDropWarning, - spanToV2JSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -35,7 +33,7 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia }; // key: traceId-segmentSpanId - const spanTreeMap = new Map>(); + const spanTreeMap = new Map>(); return { name: 'SpanStreaming', @@ -57,13 +55,13 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia return; } - client.on('enqueueSpan', span => { - const spanTreeMapKey = getSpanTreeMapKey(span); + client.on('enqueueSpan', spanJSON => { + const spanTreeMapKey = getSpanTreeMapKey(spanJSON as SpanV2JSONWithSegmentRef); const spanBuffer = spanTreeMap.get(spanTreeMapKey); if (spanBuffer) { - spanBuffer.add(span); + spanBuffer.add(spanJSON as SpanV2JSONWithSegmentRef); } else { - spanTreeMap.set(spanTreeMapKey, new Set([span])); + spanTreeMap.set(spanTreeMapKey, new Set([spanJSON as SpanV2JSONWithSegmentRef])); } }); @@ -87,7 +85,7 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia interface SpanProcessingOptions { client: Client; - spanTreeMap: Map>; + spanTreeMap: Map>; batchLimit: number; beforeSendSpan: ((span: SpanV2JSON) => SpanV2JSON) | undefined; } @@ -95,8 +93,8 @@ interface SpanProcessingOptions { /** * Just the traceid alone isn't enough because there can be multiple span trees with the same traceid. */ -function getSpanTreeMapKey(span: Span): string { - return `${span.spanContext().traceId}-${INTERNAL_getSegmentSpan(span).spanContext().spanId}`; +function getSpanTreeMapKey(spanJSON: SpanV2JSONWithSegmentRef): string { + return `${spanJSON.trace_id}-${spanJSON._segmentSpan?.spanContext().spanId || spanJSON.span_id}`; } function sendSegment( @@ -104,7 +102,8 @@ function sendSegment( { client, spanTreeMap, batchLimit, beforeSendSpan }: SpanProcessingOptions, ): void { const traceId = segmentSpan.spanContext().traceId; - const spanTreeMapKey = getSpanTreeMapKey(segmentSpan); + const segmentSpanId = segmentSpan.spanContext().spanId; + const spanTreeMapKey = `${traceId}-${segmentSpanId}`; const spansOfTrace = spanTreeMap.get(spanTreeMapKey); if (!spansOfTrace?.size) { @@ -112,12 +111,16 @@ function sendSegment( return; } - const finalSpans = Array.from(spansOfTrace).map(span => { - const spanJson = spanToV2JSON(span); + // Apply beforeSendSpan callback and clean up segment span references + const finalSpans = Array.from(spansOfTrace).map(spanJSON => { + // Remove the segment span reference before processing + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _segmentSpan, ...cleanSpanJSON } = spanJSON; + if (beforeSendSpan) { - return applyBeforeSendSpanCallback(spanJson, beforeSendSpan); + return applyBeforeSendSpanCallback(cleanSpanJSON, beforeSendSpan); } - return spanJson; + return cleanSpanJSON; }); const batches: SpanV2JSON[][] = []; @@ -127,6 +130,7 @@ function sendSegment( DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batch${batches.length === 1 ? '' : 'es'}`); + // Compute DSC from the segment span (passed as parameter) const dsc = getDynamicSamplingContextFromSpan(segmentSpan); for (const batch of batches) { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index fa877c4ac762..92b27998076d 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -618,13 +618,13 @@ export abstract class Client { */ public on(hook: 'afterSegmentSpanEnd', callback: (span: Span) => void): () => void; /** - * Register a callback for when the span is ready to be enqueued into the span buffer. + * Register a callback for when the span JSON is ready to be enqueued into the span buffer. */ - public on(hook: 'enqueueSpan', callback: (span: Span) => void): () => void; + public on(hook: 'enqueueSpan', callback: (spanJSON: SpanV2JSON) => void): () => void; /** - * Register a callback for when a span is processed, to add some attributes to the span. + * Register a callback for when a span JSON is processed, to add some attributes to the span JSON. */ - public on(hook: 'processSpan', callback: (span: Span, hint: { readOnlySpan: SpanV2JSON }) => void): () => void; + public on(hook: 'processSpan', callback: (spanJSON: SpanV2JSON, hint: { readOnlySpan: Span }) => void): () => void; /** * Register a callback for when an idle span is allowed to auto-finish. @@ -901,12 +901,12 @@ export abstract class Client { // Hooks reserved for Span-First span processing: /** Fire a hook after the `spanEnd` hook */ public emit(hook: 'afterSpanEnd', span: Span): void; - /** Fire a hook after a span is processed, to add some attributes to the span. */ - public emit(hook: 'processSpan', span: Span, hint: { readOnlySpan: SpanV2JSON }): void; + /** Fire a hook after a span is processed, to add some attributes to the span JSON. */ + public emit(hook: 'processSpan', spanJSON: SpanV2JSON, hint: { readOnlySpan: Span }): void; /** Fire a hook after the `segmentSpanEnd` hook is fired. */ public emit(hook: 'afterSegmentSpanEnd', span: Span): void; /** Fire a hook after a span ready to be enqueued into the span buffer. */ - public emit(hook: 'enqueueSpan', span: Span): void; + public emit(hook: 'enqueueSpan', spanJSON: SpanV2JSON): void; /** * Fire a hook indicating that an idle span is allowed to auto finish. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1b7acd5b60e9..4a3966167d05 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,7 +91,8 @@ export { showSpanDropWarning, } from './utils/spanUtils'; export { captureSpan } from './spans/captureSpan'; -export { safeSetSpanAttributes } from './spans/spanFirstUtils'; +export type { SpanV2JSONWithSegmentRef } from './spans/captureSpan'; +export { safeSetSpanAttributes, safeSetSpanJSONAttributes } from './spans/spanFirstUtils'; export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index 1b4d54fe3355..dd83dd5c5be4 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -20,17 +20,32 @@ import type { Span, SpanV2JSON } from '../types-hoist/span'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { debug } from '../utils/debug-logger'; import { INTERNAL_getSegmentSpan, spanToV2JSON } from '../utils/spanUtils'; -import { safeSetSpanAttributes } from './spanFirstUtils'; +import { safeSetSpanJSONAttributes } from './spanFirstUtils'; /** - * Captures a span and returns it to the caller, to be enqueued for sending. + * A SpanV2JSON with an attached reference to the segment span. + * This reference is used to compute dynamic sampling context before sending. + * The reference MUST be removed before sending the span envelope. */ -export function captureSpan(span: Span, client = getClient()): Span | void { +export interface SpanV2JSONWithSegmentRef extends SpanV2JSON { + _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). + */ +export function captureSpan(span: Span, client = getClient()): SpanV2JSONWithSegmentRef | void { if (!client) { DEBUG_BUILD && debug.warn('No client available to capture span.'); return; } + // Convert to JSON FIRST - we cannot write to an already-ended span + const spanJSON = spanToV2JSON(span) as SpanV2JSONWithSegmentRef; + const segmentSpan = INTERNAL_getSegmentSpan(span); const serializedSegmentSpan = spanToV2JSON(segmentSpan); @@ -39,44 +54,39 @@ export function captureSpan(span: Span, client = getClient()): Span | void { const originalAttributes = serializedSegmentSpan.attributes ?? {}; - applyCommonSpanAttributes(span, serializedSegmentSpan, client, finalScopeData, originalAttributes); + applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData, originalAttributes); if (span === segmentSpan) { - applyScopeToSegmentSpan(span, finalScopeData, originalAttributes); + applyScopeToSegmentSpan(spanJSON, finalScopeData, originalAttributes); } - // Allow integrations to add additional data to span. Pass in a serialized - // span to avoid having to potentially serialize the span in every integration - // (for improved performance). - client.emit('processSpan', span, { readOnlySpan: spanToV2JSON(span) }); - - // Wondering where we apply the beforeSendSpan callback? - // We apply it directly before sending the span, - // so whenever the buffer this span gets enqueued in is being flushed. - // Why? Because we have to enqueue the span instance itself, not a JSON object. - // We could temporarily convert to JSON here but this means that we'd then again - // have to mutate the `span` instance (doesn't work for every kind of object mutation) - // or construct a fully new span object. The latter is risky because users (or we) could hold - // references to the original span instance. - client.emit('enqueueSpan', span); - - return span; + // Attach segment span reference for DSC generation at send time + spanJSON._segmentSpan = segmentSpan; + + // Allow integrations to add additional data to the span JSON + client.emit('processSpan', spanJSON, { readOnlySpan: span }); + + // Enqueue the JSON representation for sending + // Note: We now enqueue JSON instead of the span instance to avoid mutating ended spans + client.emit('enqueueSpan', spanJSON); + + return spanJSON; } function applyScopeToSegmentSpan( - segmentSpan: Span, + segmentSpanJSON: SpanV2JSON, scopeData: ScopeData, originalAttributes: SerializedAttributes, ): void { // TODO: Apply all scope data from auto instrumentation (contexts, request) to segment span const { attributes } = scopeData; if (attributes) { - safeSetSpanAttributes(segmentSpan, attributes, originalAttributes); + safeSetSpanJSONAttributes(segmentSpanJSON, attributes, originalAttributes); } } function applyCommonSpanAttributes( - span: Span, + spanJSON: SpanV2JSON, serializedSegmentSpan: SpanV2JSON, client: Client, scopeData: ScopeData, @@ -86,8 +96,8 @@ function applyCommonSpanAttributes( const { release, environment, sendDefaultPii } = client.getOptions(); // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) - safeSetSpanAttributes( - span, + safeSetSpanJSONAttributes( + spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment, diff --git a/packages/core/src/spans/spanFirstUtils.ts b/packages/core/src/spans/spanFirstUtils.ts index 10a5b2b3439e..4fd53f3c213a 100644 --- a/packages/core/src/spans/spanFirstUtils.ts +++ b/packages/core/src/spans/spanFirstUtils.ts @@ -1,7 +1,8 @@ import type { RawAttributes } from '../attributes'; import { isAttributeObject } from '../attributes'; import type { SerializedAttributes } from '../types-hoist/attributes'; -import type { Span } from '../types-hoist/span'; +import type { Span, SpanV2JSON } from '../types-hoist/span'; +import { attributeValueToSerializedAttribute } from '../utils/attributes'; /** * Only set a span attribute if it is not already set. @@ -18,6 +19,26 @@ export function safeSetSpanAttributes( }); } +/** + * Only set a span JSON attribute if it is not already set. + * This is used to safely set attributes on JSON objects without mutating already-ended span instances. + */ +export function safeSetSpanJSONAttributes( + spanJSON: SpanV2JSON, + newAttributes: RawAttributes>, + originalAttributeKeys: SerializedAttributes | undefined, +): void { + if (!spanJSON.attributes) { + spanJSON.attributes = {}; + } + + Object.keys(newAttributes).forEach(key => { + if (!originalAttributeKeys?.[key]) { + setAttributeOnSpanJSONWithMaybeUnit(spanJSON, key, newAttributes[key]); + } + }); +} + function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attributeValue: unknown): void { if (isAttributeObject(attributeValue)) { const { value, unit } = attributeValue; @@ -34,6 +55,35 @@ function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attri } } +function setAttributeOnSpanJSONWithMaybeUnit( + spanJSON: SpanV2JSON, + attributeKey: string, + attributeValue: unknown, +): void { + // Ensure attributes object exists (it's initialized in safeSetSpanJSONAttributes) + if (!spanJSON.attributes) { + return; + } + + if (isAttributeObject(attributeValue)) { + const { value, unit } = attributeValue; + + if (isSupportedSerializableType(value)) { + spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(value); + } + + if (unit) { + spanJSON.attributes[`${attributeKey}.unit`] = attributeValueToSerializedAttribute(unit); + } + } else if (isSupportedSerializableType(attributeValue)) { + spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(attributeValue); + } +} + function isSupportedAttributeType(value: unknown): value is Parameters[1] { return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value); } + +function isSupportedSerializableType(value: unknown): boolean { + return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value); +} diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 8ab20e9dfd4c..3e0c8b909169 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -136,6 +136,7 @@ export { wrapMcpServerWithSentry, featureFlagsIntegration, metrics, + withStreamSpan, } from '@sentry/core'; export type { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bb655b87fc42..1ffe572bc803 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -193,4 +193,5 @@ export { cron, NODE_VERSION, validateOpenTelemetrySetup, + withStreamSpan, } from '@sentry/node-core'; diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ff471cef7a79..e81768269c7d 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; -import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { ATTR_HTTP_RESPONSE_STATUS_CODE, SEMATTRS_HTTP_STATUS_CODE } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes, @@ -391,7 +391,10 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS }); } -function getSpanData(span: ReadableSpan): { +/** + * Get span data from the OTEL span + */ +export function getSpanData(span: ReadableSpan): { data: Record; op?: string; description: string; diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index 956c98a9654e..2feea6cd6e7e 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -15,7 +15,7 @@ import { import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes'; import type { ISentrySpanExporter } from './spanExporter'; import { SentrySpanExporter } from './spanExporter'; -import { StreamingSpanExporter } from './streamedSpanExporter'; +import { StreamingSpanExporter } from './streamingSpanExporter'; import { getScopesFromContext } from './utils/contextData'; import { setIsSetup } from './utils/setupCheck'; @@ -100,12 +100,6 @@ export class SentrySpanProcessor implements SpanProcessorInterface { this._client?.emit('spanEnd', span); - if (this._client?.getOptions().traceLifecycle === 'stream') { - // we probably don't need to emit afterSpanEnd here but can call captureSpan directly. - // might need to revisit but let's see. - captureSpan(span, this._client); - } else { - this._exporter.export(span); - } + this._exporter.export(span); } } From 588e0a1d72d9abedb5b9e48d98f3e278d40882d5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 10 Dec 2025 15:50:03 +0100 Subject: [PATCH 39/58] wip --- .../tracing/meta-tags-twp-errors/test.ts | 13 ----- .../browser/src/integrations/spanstreaming.ts | 23 +------- packages/core/src/client.ts | 13 ++++- packages/core/src/index.ts | 2 +- packages/core/src/spans/captureSpan.ts | 56 ++++++++++--------- packages/core/src/spans/spanFirstUtils.ts | 16 ++++++ packages/core/src/types-hoist/span.ts | 12 ++++ .../node-core/src/integrations/context.ts | 27 ++++++++- 8 files changed, 98 insertions(+), 64 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts index c1859a3a67b6..d58f35b02972 100644 --- a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts @@ -1,6 +1,5 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -import { run } from 'node:test'; describe('errors in TwP mode have same trace in trace context and getTraceData()', () => { afterAll(() => { @@ -9,16 +8,11 @@ describe('errors in TwP mode have same trace in trace context and getTraceData() // In a request handler, the spanId is consistent inside of the request test('in incoming request', async () => { - let firstTraceId: string | undefined; - const runner = createRunner(__dirname, 'server.js') .expect({ event: event => { const { contexts } = event; const { trace_id, span_id } = contexts?.trace || {}; - if (!firstTraceId) { - firstTraceId = trace_id; - } expect(trace_id).toMatch(/^[a-f\d]{32}$/); expect(span_id).toMatch(/^[a-f\d]{16}$/); @@ -34,15 +28,8 @@ describe('errors in TwP mode have same trace in trace context and getTraceData() expect(traceData.metaTags).not.toContain('sentry-sampled='); }, }) - .expect({ - event: event => { - expect(event.contexts?.trace?.trace_id).toBeDefined(); - expect(event.contexts?.trace?.trace_id).toBe(firstTraceId); - }, - }) .start(); runner.makeRequest('get', '/test'); - runner.makeRequest('get', '/test'); await runner.completed(); }); diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index be7b2f328e52..e1d215d8c29d 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -6,7 +6,6 @@ import { defineIntegration, getDynamicSamplingContextFromSpan, isV2BeforeSendSpanCallback, - showSpanDropWarning, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -73,10 +72,9 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia // TODO: This will change once we have more concrete ideas about a universal SDK data buffer. client.on('afterSegmentSpanEnd', segmentSpan => { sendSegment(segmentSpan, { - spanTreeMap: spanTreeMap, + spanTreeMap, client, batchLimit: options.batchLimit, - beforeSendSpan, }); }); }, @@ -87,7 +85,6 @@ interface SpanProcessingOptions { client: Client; spanTreeMap: Map>; batchLimit: number; - beforeSendSpan: ((span: SpanV2JSON) => SpanV2JSON) | undefined; } /** @@ -97,10 +94,7 @@ function getSpanTreeMapKey(spanJSON: SpanV2JSONWithSegmentRef): string { return `${spanJSON.trace_id}-${spanJSON._segmentSpan?.spanContext().spanId || spanJSON.span_id}`; } -function sendSegment( - segmentSpan: Span, - { client, spanTreeMap, batchLimit, beforeSendSpan }: SpanProcessingOptions, -): void { +function sendSegment(segmentSpan: Span, { client, spanTreeMap, batchLimit }: SpanProcessingOptions): void { const traceId = segmentSpan.spanContext().traceId; const segmentSpanId = segmentSpan.spanContext().spanId; const spanTreeMapKey = `${traceId}-${segmentSpanId}`; @@ -116,10 +110,6 @@ function sendSegment( // Remove the segment span reference before processing // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _segmentSpan, ...cleanSpanJSON } = spanJSON; - - if (beforeSendSpan) { - return applyBeforeSendSpanCallback(cleanSpanJSON, beforeSendSpan); - } return cleanSpanJSON; }); @@ -145,12 +135,3 @@ function sendSegment( spanTreeMap.delete(spanTreeMapKey); } - -function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON { - const modifedSpan = beforeSendSpan(span); - if (!modifedSpan) { - showSpanDropWarning(); - return span; - } - return modifedSpan; -} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 92b27998076d..1cacceeba007 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -31,7 +31,14 @@ 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, SpanV2JSON } from './types-hoist/span'; +import type { + Span, + SpanAttributes, + SpanContextData, + SpanJSON, + SpanV2JSON, + SpanV2JSONWithSegmentRef, +} from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; @@ -620,7 +627,7 @@ export abstract class Client { /** * Register a callback for when the span JSON is ready to be enqueued into the span buffer. */ - public on(hook: 'enqueueSpan', callback: (spanJSON: SpanV2JSON) => void): () => void; + public on(hook: 'enqueueSpan', callback: (spanJSON: SpanV2JSONWithSegmentRef) => void): () => void; /** * Register a callback for when a span JSON is processed, to add some attributes to the span JSON. */ @@ -906,7 +913,7 @@ export abstract class Client { /** Fire a hook after the `segmentSpanEnd` hook is fired. */ public emit(hook: 'afterSegmentSpanEnd', span: Span): void; /** Fire a hook after a span ready to be enqueued into the span buffer. */ - public emit(hook: 'enqueueSpan', spanJSON: SpanV2JSON): void; + public emit(hook: 'enqueueSpan', spanJSON: SpanV2JSONWithSegmentRef): void; /** * Fire a hook indicating that an idle span is allowed to auto finish. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a3966167d05..f948bfc7d10c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,7 +91,6 @@ export { showSpanDropWarning, } from './utils/spanUtils'; export { captureSpan } from './spans/captureSpan'; -export type { SpanV2JSONWithSegmentRef } from './spans/captureSpan'; export { safeSetSpanAttributes, safeSetSpanJSONAttributes } from './spans/spanFirstUtils'; export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; @@ -452,6 +451,7 @@ export type { SpanContextData, TraceFlag, SpanV2JSON, + SpanV2JSONWithSegmentRef, } 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/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index dd83dd5c5be4..77e82186b46d 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -1,3 +1,4 @@ +import { Attributes, RawAttribute, RawAttributes } from '../attributes'; import type { Client } from '../client'; import { getClient, getGlobalScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; @@ -16,35 +17,27 @@ import { } from '../semanticAttributes'; import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { SerializedAttributes } from '../types-hoist/attributes'; +import { Contexts } from '../types-hoist/context'; import type { Span, SpanV2JSON } from '../types-hoist/span'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; +import { isV2BeforeSendSpanCallback } from '../utils/beforeSendSpan'; import { debug } from '../utils/debug-logger'; import { INTERNAL_getSegmentSpan, spanToV2JSON } from '../utils/spanUtils'; -import { safeSetSpanJSONAttributes } from './spanFirstUtils'; - -/** - * A SpanV2JSON with an attached reference to the segment span. - * This reference is used to compute dynamic sampling context before sending. - * The reference MUST be removed before sending the span envelope. - */ -export interface SpanV2JSONWithSegmentRef extends SpanV2JSON { - _segmentSpan: Span; -} - +import { applyBeforeSendSpanCallback, safeSetSpanJSONAttributes } from './spanFirstUtils'; /** * 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). */ -export function captureSpan(span: Span, client = getClient()): SpanV2JSONWithSegmentRef | void { +export function captureSpan(span: Span, client = getClient()): void { if (!client) { DEBUG_BUILD && debug.warn('No client available to capture span.'); return; } // Convert to JSON FIRST - we cannot write to an already-ended span - const spanJSON = spanToV2JSON(span) as SpanV2JSONWithSegmentRef; + const spanJSON = spanToV2JSON(span); const segmentSpan = INTERNAL_getSegmentSpan(span); const serializedSegmentSpan = spanToV2JSON(segmentSpan); @@ -60,17 +53,20 @@ export function captureSpan(span: Span, client = getClient()): SpanV2JSONWithSeg applyScopeToSegmentSpan(spanJSON, finalScopeData, originalAttributes); } - // Attach segment span reference for DSC generation at send time - spanJSON._segmentSpan = segmentSpan; - // Allow integrations to add additional data to the span JSON client.emit('processSpan', spanJSON, { readOnlySpan: span }); - // Enqueue the JSON representation for sending - // Note: We now enqueue JSON instead of the span instance to avoid mutating ended spans - client.emit('enqueueSpan', spanJSON); + const beforeSendSpan = client.getOptions().beforeSendSpan; + const processedSpan = isV2BeforeSendSpanCallback(beforeSendSpan) + ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) + : spanJSON; - return spanJSON; + const spanWithRef = { + ...processedSpan, + _segmentSpan: segmentSpan, + }; + + client.emit('enqueueSpan', spanWithRef); } function applyScopeToSegmentSpan( @@ -78,11 +74,10 @@ function applyScopeToSegmentSpan( scopeData: ScopeData, originalAttributes: SerializedAttributes, ): void { - // TODO: Apply all scope data from auto instrumentation (contexts, request) to segment span - const { attributes } = scopeData; - if (attributes) { - safeSetSpanJSONAttributes(segmentSpanJSON, attributes, originalAttributes); - } + // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span + const { contexts } = scopeData; + + safeSetSpanJSONAttributes(segmentSpanJSON, contextsToAttributes(contexts), originalAttributes); } function applyCommonSpanAttributes( @@ -113,6 +108,7 @@ function applyCommonSpanAttributes( [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, } : {}), + ...scopeData.attributes, }, originalAttributes, ); @@ -129,3 +125,13 @@ function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | und } return finalScopeData; } + +function contextsToAttributes(contexts: Contexts): RawAttributes> { + return { + 'os.build_id': contexts.os?.build, + 'os.name': contexts.os?.name, + 'os.version': contexts.os?.version, + // TODO: Add to Sentry SemConv + 'os.kernel_version': contexts.os?.kernel_version, + }; +} diff --git a/packages/core/src/spans/spanFirstUtils.ts b/packages/core/src/spans/spanFirstUtils.ts index 4fd53f3c213a..01e090b3184b 100644 --- a/packages/core/src/spans/spanFirstUtils.ts +++ b/packages/core/src/spans/spanFirstUtils.ts @@ -3,6 +3,7 @@ import { isAttributeObject } from '../attributes'; import type { SerializedAttributes } from '../types-hoist/attributes'; import type { Span, SpanV2JSON } from '../types-hoist/span'; import { attributeValueToSerializedAttribute } from '../utils/attributes'; +import { showSpanDropWarning } from '../utils/spanUtils'; /** * Only set a span attribute if it is not already set. @@ -55,6 +56,21 @@ function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attri } } +/** + * Apply a user-provided beforeSendSpan callback to a span JSON. + */ +export function applyBeforeSendSpanCallback( + span: SpanV2JSON, + beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON, +): SpanV2JSON { + const modifedSpan = beforeSendSpan(span); + if (!modifedSpan) { + showSpanDropWarning(); + return span; + } + return modifedSpan; +} + function setAttributeOnSpanJSONWithMaybeUnit( spanJSON: SpanV2JSON, attributeKey: string, diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index 0295f21d19dd..76e11072af3d 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -35,6 +35,9 @@ export type SpanAttributes = Partial<{ /** This type is aligned with the OpenTelemetry TimeInput type. */ export type SpanTimeInput = HrTime | number | Date; +/** + * JSON representation of a v2 span, as it should be sent to Sentry. + */ export interface SpanV2JSON { trace_id: string; parent_span_id?: string; @@ -48,6 +51,15 @@ export interface SpanV2JSON { links?: SpanLinkJSON[]; } +/** + * A SpanV2JSON with an attached reference to the segment span. + * This reference is used to compute dynamic sampling context before sending. + * The reference MUST be removed before sending the span envelope. + */ +export interface SpanV2JSONWithSegmentRef extends SpanV2JSON { + _segmentSpan: Span; +} + export type SerializedSpanContainer = { items: Array; }; diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index 16cdadd9383b..1f65ead497f9 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -15,7 +15,7 @@ import type { IntegrationFn, OsContext, } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { debug, defineIntegration, getGlobalScope } from '@sentry/core'; export const readFileAsync = promisify(readFile); export const readDirAsync = promisify(readdir); @@ -107,6 +107,31 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { return { name: INTEGRATION_NAME, + setupOnce() { + console.log('xx setupOnce'); + _getContexts() + .then(updatedContext => { + const globalScope = getGlobalScope(); + const previousContexts = globalScope.getScopeData().contexts; + + const contexts = { + app: { ...updatedContext.app, ...previousContexts?.app }, + os: { ...updatedContext.os, ...previousContexts?.os }, + device: { ...updatedContext.device, ...previousContexts?.device }, + culture: { ...updatedContext.culture, ...previousContexts?.culture }, + cloud_resource: { ...updatedContext.cloud_resource, ...previousContexts?.cloud_resource }, + }; + + Object.keys(contexts).forEach(key => { + globalScope.setContext(key, contexts[key as keyof Event['contexts']]); + }); + + console.log('xx set contexts to global scope', contexts); + }) + .catch(() => { + debug.warn(`[${INTEGRATION_NAME}] Failed to get contexts from Node`); + }); + }, // TODO (span-streaming): we probably need to apply this to spans via a hook IF we decide to apply contexts to (segment) spans processEvent(event) { return addContext(event); From 4961b808ebd4a295d4da65097d12a7851c14a67c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 10 Dec 2025 16:36:00 +0100 Subject: [PATCH 40/58] add exporter --- .../src/streamingSpanExporter.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 packages/opentelemetry/src/streamingSpanExporter.ts diff --git a/packages/opentelemetry/src/streamingSpanExporter.ts b/packages/opentelemetry/src/streamingSpanExporter.ts new file mode 100644 index 000000000000..707683a8eb25 --- /dev/null +++ b/packages/opentelemetry/src/streamingSpanExporter.ts @@ -0,0 +1,175 @@ +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { Client, Span, SpanV2JSON } from '@sentry/core'; +import { + type SpanV2JSONWithSegmentRef, + captureSpan, + createSpanV2Envelope, + debug, + getDynamicSamplingContextFromSpan, + safeSetSpanJSONAttributes, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import { type ISentrySpanExporter, getSpanData } from './spanExporter'; + +type StreamingSpanExporterOptions = { + flushInterval?: number; + maxSpanLimit?: number; +}; + +/** + * A Sentry-specific exporter that buffers span JSON objects and streams them to Sentry + * in Span v2 envelopes. This exporter works with pre-serialized span JSON rather than + * OTel span instances to avoid mutating already-ended spans. + */ +export class StreamingSpanExporter implements ISentrySpanExporter { + private _flushInterval: number; + private _maxSpanLimit: number; + + private _spanTreeMap: Map>; + + private _flushIntervalId: NodeJS.Timeout | null; + + private _client: Client; + + public constructor(client: Client, options?: StreamingSpanExporterOptions) { + this._spanTreeMap = new Map(); + this._client = client; + + const safeMaxSpanLimit = + options?.maxSpanLimit && options.maxSpanLimit > 0 && options.maxSpanLimit <= 1000 ? options.maxSpanLimit : 1000; + const safeFlushInterval = options?.flushInterval && options?.flushInterval > 0 ? options.flushInterval : 5_000; + this._flushInterval = safeFlushInterval; + this._maxSpanLimit = safeMaxSpanLimit; + + this._flushIntervalId = setInterval(() => { + this.flush(); + }, this._flushInterval); + + this._client.on('processSpan', (spanJSON, hint) => { + const { readOnlySpan } = hint; + // TODO: This can be simplified by using spanJSON to get the data instead of the readOnlySpan + // for now this is the easiest backwards-compatible way to get the data. + const { op, description, data, origin = 'manual' } = getSpanData(readOnlySpan as unknown as ReadableSpan); + const allData = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + ...data, + }; + safeSetSpanJSONAttributes(spanJSON, allData, spanJSON.attributes); + spanJSON.name = description; + }); + + this._client.on('enqueueSpan', spanJSON => { + const traceId = spanJSON.trace_id; + let traceBucket = this._spanTreeMap.get(traceId); + if (traceBucket) { + traceBucket.add(spanJSON); + } else { + traceBucket = new Set([spanJSON]); + this._spanTreeMap.set(traceId, traceBucket); + } + + if (traceBucket.size >= this._maxSpanLimit) { + this._flushTrace(traceId); + this._debounceFlushInterval(); + } + }); + } + + /** + * Enqueue a span JSON into the buffer + */ + public export(span: ReadableSpan & Span): void { + captureSpan(span, this._client); + } + + /** + * Try to flush any pending spans immediately. + * This is called internally by the exporter (via _debouncedFlush), + * but can also be triggered externally if we force-flush. + */ + public flush(): void { + if (!this._spanTreeMap.size) { + return; + } + + debug.log(`Flushing span tree map with ${this._spanTreeMap.size} traces`); + + this._spanTreeMap.forEach((_, traceId) => { + this._flushTrace(traceId); + }); + this._debounceFlushInterval(); + } + + /** + * Clear the exporter. + * This is called when the span processor is shut down. + */ + public clear(): void { + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + this._flushIntervalId = null; + } + // TODO (span-streaming): record client outcome for leftover spans? + this._spanTreeMap.clear(); + } + + /** + * Flush a trace from the span tree map. + */ + private _flushTrace(traceId: string): void { + const traceBucket = this._spanTreeMap.get(traceId); + if (!traceBucket) { + return; + } + + if (!traceBucket.size) { + this._spanTreeMap.delete(traceId); + return; + } + + // we checked against empty bucket above, so we can safely get the first span JSON here + const firstSpanJSON = traceBucket.values().next().value; + + // Extract the segment span reference for DSC calculation + const segmentSpan = firstSpanJSON?._segmentSpan; + if (!segmentSpan) { + DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); + this._spanTreeMap.delete(traceId); + return; + } + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + // Clean up segment span references before sending + const cleanedSpans: SpanV2JSON[] = Array.from(traceBucket).map(spanJSON => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _segmentSpan, ...cleanSpanJSON } = spanJSON; + return cleanSpanJSON; + }); + + const envelope = createSpanV2Envelope(cleanedSpans, dsc, this._client); + + 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 span stream envelope:', reason); + }); + + this._spanTreeMap.delete(traceId); + } + + /** + * Debounce (reset) the flush interval. + */ + private _debounceFlushInterval(): void { + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._flushIntervalId = setInterval(() => { + this.flush(); + }, this._flushInterval); + } +} From 1a28c3a50003e309b7b513514b4336d7a246940d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 10 Dec 2025 17:55:21 +0100 Subject: [PATCH 41/58] more contexts --- packages/core/src/spans/captureSpan.ts | 24 ++++++++++++++++ .../node-core/src/integrations/context.ts | 28 +++++++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index 77e82186b46d..ddb8274eacc4 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -126,12 +126,36 @@ function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | und return finalScopeData; } +// TODO: This should likely live in the Context integration since most of this data is only avialable in server runtime contexts function contextsToAttributes(contexts: Contexts): RawAttributes> { return { + // os context 'os.build_id': contexts.os?.build, 'os.name': contexts.os?.name, 'os.version': contexts.os?.version, // TODO: Add to Sentry SemConv 'os.kernel_version': contexts.os?.kernel_version, + + // runtime context + // TODO: Add to Sentry SemConv + 'runtime.name': contexts.runtime?.name, + // TODO: Add to Sentry SemConv + 'runtime.version': contexts.runtime?.version, + + // TODO: All of them need to be added to Sentry SemConv (except family and model) + ...(contexts.app + ? Object.fromEntries(Object.entries(contexts.app).map(([key, value]) => [`app.${key}`, value])) + : {}), + ...(contexts.device + ? Object.fromEntries(Object.entries(contexts.device).map(([key, value]) => [`device.${key}`, value])) + : {}), + ...(contexts.culture + ? Object.fromEntries(Object.entries(contexts.culture).map(([key, value]) => [`culture.${key}`, value])) + : {}), + ...(contexts.cloud_resource + ? Object.fromEntries( + Object.entries(contexts.cloud_resource).map(([key, value]) => [`cloud_resource.${key}`, value]), + ) + : {}), }; } diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index 1f65ead497f9..f7fbbc65c69f 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -15,7 +15,13 @@ import type { IntegrationFn, OsContext, } from '@sentry/core'; -import { debug, defineIntegration, getGlobalScope } from '@sentry/core'; +import { + debug, + defineIntegration, + getCapturedScopesOnSpan, + getGlobalScope, + INTERNAL_getSegmentSpan, +} from '@sentry/core'; export const readFileAsync = promisify(readFile); export const readDirAsync = promisify(readdir); @@ -107,8 +113,8 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - console.log('xx setupOnce'); + setup(client) { + // first set all contexts on the global scope _getContexts() .then(updatedContext => { const globalScope = getGlobalScope(); @@ -120,17 +126,29 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { device: { ...updatedContext.device, ...previousContexts?.device }, culture: { ...updatedContext.culture, ...previousContexts?.culture }, cloud_resource: { ...updatedContext.cloud_resource, ...previousContexts?.cloud_resource }, + runtime: { name: 'node', version: global.process.version, ...previousContexts?.runtime }, }; Object.keys(contexts).forEach(key => { globalScope.setContext(key, contexts[key as keyof Event['contexts']]); }); - - console.log('xx set contexts to global scope', contexts); }) .catch(() => { debug.warn(`[${INTEGRATION_NAME}] Failed to get contexts from Node`); }); + + client.on('spanEnd', span => { + if (INTERNAL_getSegmentSpan(span) !== span) { + return; + } + const currentScopeOfSpan = getCapturedScopesOnSpan(span).scope; + if (currentScopeOfSpan) { + const updatedContext = _updateContext(getGlobalScope().getScopeData().contexts); + Object.keys(updatedContext).forEach(key => { + currentScopeOfSpan.setContext(key, updatedContext[key as keyof Event['contexts']] ?? null); + }); + } + }); }, // TODO (span-streaming): we probably need to apply this to spans via a hook IF we decide to apply contexts to (segment) spans processEvent(event) { From 9c3d3fd54377c9c3e54c90c1f1bf68f8645c3f9a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 11 Dec 2025 13:41:28 +0100 Subject: [PATCH 42/58] add unit tests for captureSpan pipeline and utils --- .../browser/src/integrations/httpcontext.ts | 12 +- packages/core/src/attributes.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/spans/captureSpan.ts | 92 ++--- packages/core/src/spans/spanFirstUtils.ts | 137 +++++--- packages/core/src/types-hoist/attributes.ts | 4 +- .../core/src/utils/applyScopeDataToEvent.ts | 5 + .../core/test/lib/spans/captureSpan.test.ts | 325 ++++++++++++++++++ .../test/lib/spans/spanFirstUtils.test.ts | 155 +++++++++ .../src/streamingSpanExporter.ts | 2 +- 10 files changed, 606 insertions(+), 130 deletions(-) create mode 100644 packages/core/test/lib/spans/captureSpan.test.ts create mode 100644 packages/core/test/lib/spans/spanFirstUtils.test.ts diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 7bc1bdbe990b..4c9884ddb848 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -27,14 +27,10 @@ export const httpContextIntegration = defineIntegration(() => { const attributeHeaders = httpHeadersToSpanAttributes(headers); - safeSetSpanJSONAttributes( - spanJSON, - { - [SEMANTIC_ATTRIBUTE_URL_FULL]: url, - ...attributeHeaders, - }, - spanJSON.attributes, - ); + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_URL_FULL]: url, + ...attributeHeaders, + }); } }); } diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index d979d5c4350f..0ed74a31ad67 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -42,7 +42,7 @@ export type AttributeObject = { // Unfortunately, we loose type safety if we did something like Exclude // so therefore we unionize between the three supported unit categories. -type AttributeUnit = DurationUnit | InformationUnit | FractionUnit; +export type AttributeUnit = DurationUnit | InformationUnit | FractionUnit; /* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ export type ValidatedAttributes = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f948bfc7d10c..2c19ee5e83c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,7 +91,7 @@ export { showSpanDropWarning, } from './utils/spanUtils'; export { captureSpan } from './spans/captureSpan'; -export { safeSetSpanAttributes, safeSetSpanJSONAttributes } from './spans/spanFirstUtils'; +export { safeSetSpanJSONAttributes } from './spans/spanFirstUtils'; export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index ddb8274eacc4..5ac3826c42e9 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -1,4 +1,3 @@ -import { Attributes, RawAttribute, RawAttributes } from '../attributes'; import type { Client } from '../client'; import { getClient, getGlobalScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; @@ -17,13 +16,12 @@ import { } from '../semanticAttributes'; import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { SerializedAttributes } from '../types-hoist/attributes'; -import { Contexts } from '../types-hoist/context'; import type { Span, SpanV2JSON } from '../types-hoist/span'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { isV2BeforeSendSpanCallback } from '../utils/beforeSendSpan'; import { debug } from '../utils/debug-logger'; import { INTERNAL_getSegmentSpan, spanToV2JSON } from '../utils/spanUtils'; -import { applyBeforeSendSpanCallback, safeSetSpanJSONAttributes } from './spanFirstUtils'; +import { applyBeforeSendSpanCallback, contextsToAttributes, safeSetSpanJSONAttributes } from './spanFirstUtils'; /** * Captures a span and returns a JSON representation to be enqueued for sending. * @@ -43,14 +41,13 @@ export function captureSpan(span: Span, client = getClient()): void { const serializedSegmentSpan = spanToV2JSON(segmentSpan); const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); - const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope); - const originalAttributes = serializedSegmentSpan.attributes ?? {}; + const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope); - applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData, originalAttributes); + applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); if (span === segmentSpan) { - applyScopeToSegmentSpan(spanJSON, finalScopeData, originalAttributes); + applyScopeToSegmentSpan(spanJSON, finalScopeData); } // Allow integrations to add additional data to the span JSON @@ -69,15 +66,11 @@ export function captureSpan(span: Span, client = getClient()): void { client.emit('enqueueSpan', spanWithRef); } -function applyScopeToSegmentSpan( - segmentSpanJSON: SpanV2JSON, - scopeData: ScopeData, - originalAttributes: SerializedAttributes, -): void { +function applyScopeToSegmentSpan(segmentSpanJSON: SpanV2JSON, scopeData: ScopeData): void { // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span const { contexts } = scopeData; - safeSetSpanJSONAttributes(segmentSpanJSON, contextsToAttributes(contexts), originalAttributes); + safeSetSpanJSONAttributes(segmentSpanJSON, contextsToAttributes(contexts)); } function applyCommonSpanAttributes( @@ -85,33 +78,28 @@ function applyCommonSpanAttributes( serializedSegmentSpan: SpanV2JSON, client: Client, scopeData: ScopeData, - originalAttributes: SerializedAttributes, ): 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 ?? undefined, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, - } - : {}), - ...scopeData.attributes, - }, - originalAttributes, - ); + 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, + }); } // TODO: Extract this to a helper in core. It's used in multiple places. @@ -125,37 +113,3 @@ function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | und } return finalScopeData; } - -// TODO: This should likely live in the Context integration since most of this data is only avialable in server runtime contexts -function contextsToAttributes(contexts: Contexts): RawAttributes> { - return { - // os context - 'os.build_id': contexts.os?.build, - 'os.name': contexts.os?.name, - 'os.version': contexts.os?.version, - // TODO: Add to Sentry SemConv - 'os.kernel_version': contexts.os?.kernel_version, - - // runtime context - // TODO: Add to Sentry SemConv - 'runtime.name': contexts.runtime?.name, - // TODO: Add to Sentry SemConv - 'runtime.version': contexts.runtime?.version, - - // TODO: All of them need to be added to Sentry SemConv (except family and model) - ...(contexts.app - ? Object.fromEntries(Object.entries(contexts.app).map(([key, value]) => [`app.${key}`, value])) - : {}), - ...(contexts.device - ? Object.fromEntries(Object.entries(contexts.device).map(([key, value]) => [`device.${key}`, value])) - : {}), - ...(contexts.culture - ? Object.fromEntries(Object.entries(contexts.culture).map(([key, value]) => [`culture.${key}`, value])) - : {}), - ...(contexts.cloud_resource - ? Object.fromEntries( - Object.entries(contexts.cloud_resource).map(([key, value]) => [`cloud_resource.${key}`, value]), - ) - : {}), - }; -} diff --git a/packages/core/src/spans/spanFirstUtils.ts b/packages/core/src/spans/spanFirstUtils.ts index 01e090b3184b..7cad2045b42a 100644 --- a/packages/core/src/spans/spanFirstUtils.ts +++ b/packages/core/src/spans/spanFirstUtils.ts @@ -1,25 +1,11 @@ import type { RawAttributes } from '../attributes'; import { isAttributeObject } from '../attributes'; -import type { SerializedAttributes } from '../types-hoist/attributes'; -import type { Span, SpanV2JSON } from '../types-hoist/span'; +import type { Context, Contexts } from '../types-hoist/context'; +import type { SpanV2JSON } from '../types-hoist/span'; import { attributeValueToSerializedAttribute } from '../utils/attributes'; +import { isPrimitive } from '../utils/is'; import { showSpanDropWarning } from '../utils/spanUtils'; -/** - * Only set a span attribute if it is not already set. - */ -export function safeSetSpanAttributes( - span: Span, - newAttributes: RawAttributes>, - originalAttributeKeys: SerializedAttributes | undefined, -): void { - Object.keys(newAttributes).forEach(key => { - if (!originalAttributeKeys?.[key]) { - setAttributeOnSpanWithMaybeUnit(span, key, newAttributes[key]); - } - }); -} - /** * Only set a span JSON attribute if it is not already set. * This is used to safely set attributes on JSON objects without mutating already-ended span instances. @@ -27,35 +13,25 @@ export function safeSetSpanAttributes( export function safeSetSpanJSONAttributes( spanJSON: SpanV2JSON, newAttributes: RawAttributes>, - originalAttributeKeys: SerializedAttributes | undefined, ): void { if (!spanJSON.attributes) { spanJSON.attributes = {}; } + const originalAttributes = spanJSON.attributes; + Object.keys(newAttributes).forEach(key => { - if (!originalAttributeKeys?.[key]) { - setAttributeOnSpanJSONWithMaybeUnit(spanJSON, key, newAttributes[key]); + if (!originalAttributes?.[key]) { + setAttributeOnSpanJSONWithMaybeUnit( + // type-casting here because we ensured above that the attributes object exists + spanJSON as SpanV2JSON & Required>, + key, + newAttributes[key], + ); } }); } -function setAttributeOnSpanWithMaybeUnit(span: Span, attributeKey: string, attributeValue: unknown): void { - if (isAttributeObject(attributeValue)) { - const { value, unit } = attributeValue; - - if (isSupportedAttributeType(value)) { - span.setAttribute(attributeKey, value); - } - - if (unit) { - span.setAttribute(`${attributeKey}.unit`, unit); - } - } else if (isSupportedAttributeType(attributeValue)) { - span.setAttribute(attributeKey, attributeValue); - } -} - /** * Apply a user-provided beforeSendSpan callback to a span JSON. */ @@ -72,34 +48,97 @@ export function applyBeforeSendSpanCallback( } function setAttributeOnSpanJSONWithMaybeUnit( - spanJSON: SpanV2JSON, + spanJSON: SpanV2JSON & Required>, attributeKey: string, attributeValue: unknown, ): void { - // Ensure attributes object exists (it's initialized in safeSetSpanJSONAttributes) - if (!spanJSON.attributes) { - return; - } - if (isAttributeObject(attributeValue)) { const { value, unit } = attributeValue; if (isSupportedSerializableType(value)) { spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(value); - } - - if (unit) { - spanJSON.attributes[`${attributeKey}.unit`] = attributeValueToSerializedAttribute(unit); + if (unit) { + spanJSON.attributes[attributeKey].unit = unit; + } } } else if (isSupportedSerializableType(attributeValue)) { spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(attributeValue); } } -function isSupportedAttributeType(value: unknown): value is Parameters[1] { +function isSupportedSerializableType(value: unknown): boolean { return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value); } -function isSupportedSerializableType(value: unknown): boolean { - return ['string', 'number', 'boolean'].includes(typeof value) || Array.isArray(value); +// map of attributes->context keys for those attributes that don't correspond 1:1 to the context key +const explicitAttributeToContextMapping = { + 'os.build_id': 'os.build', + 'app.name': 'app.app_name', + 'app.identifier': 'app.app_identifier', + 'app.version': 'app.app_version', + 'app.memory': 'app.app_memory', + 'app.start_time': 'app.app_start_time', +}; + +const knownContexts = ['app', 'os', 'device', 'culture', 'cloud_resource', 'runtime']; + +/** + * Converts a context object to a set of attributes. + * Only includes attributes that are primitives (for now). + * @param contexts - The context object to convert. + * @returns The attributes object. + */ +export function contextsToAttributes(contexts: Contexts): RawAttributes> { + function contextToAttribute(context: Context): Context { + return Object.keys(context).reduce( + (acc, key) => { + if (!isPrimitive(context[key])) { + return acc; + } + acc[key] = context[key]; + return acc; + }, + {} as Record, + ); + } + + const contextsWithPrimitiveValues = Object.keys(contexts).reduce((acc, key) => { + if (!knownContexts.includes(key)) { + return acc; + } + const context = contexts[key]; + if (context) { + acc[key] = contextToAttribute(context); + } + return acc; + }, {} as Contexts); + + const explicitlyMappedAttributes = Object.entries(explicitAttributeToContextMapping).reduce( + (acc, [attributeKey, contextKey]) => { + const [contextName, contextValueKey] = contextKey.split('.'); + if (contextName && contextValueKey && contextsWithPrimitiveValues[contextName]?.[contextValueKey]) { + acc[attributeKey] = contextsWithPrimitiveValues[contextName]?.[contextValueKey]; + // now we delete this key from `contextsWithPrimitiveValues` so we don't include it in the next step + delete contextsWithPrimitiveValues[contextName]?.[contextValueKey]; + } + return acc; + }, + {} as Record, + ); + + return { + ...explicitlyMappedAttributes, + ...Object.entries(contextsWithPrimitiveValues).reduce( + (acc, [contextName, contextObj]) => { + contextObj && + Object.entries(contextObj).forEach(([key, value]) => { + if (value) { + acc[`${contextName}.${key}`] = value; + } + }); + return acc; + }, + {} as Record, + ), + }; } diff --git a/packages/core/src/types-hoist/attributes.ts b/packages/core/src/types-hoist/attributes.ts index 56b3658f8c20..ca5bce15f0a6 100644 --- a/packages/core/src/types-hoist/attributes.ts +++ b/packages/core/src/types-hoist/attributes.ts @@ -1,3 +1,5 @@ +import type { AttributeUnit } from '../attributes'; + export type SerializedAttributes = Record; export type SerializedAttribute = ( | { @@ -16,5 +18,5 @@ export type SerializedAttribute = ( type: 'boolean'; value: boolean; } -) & { unit?: 'ms' | 's' | 'bytes' | 'count' | 'percent' }; +) & { unit?: AttributeUnit }; export type SerializedAttributeType = 'string' | 'integer' | 'double' | 'boolean'; diff --git a/packages/core/src/utils/applyScopeDataToEvent.ts b/packages/core/src/utils/applyScopeDataToEvent.ts index 5fcce32be2ba..c2bfc06dd216 100644 --- a/packages/core/src/utils/applyScopeDataToEvent.ts +++ b/packages/core/src/utils/applyScopeDataToEvent.ts @@ -32,6 +32,7 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void { const { extra, tags, + attributes, user, contexts, level, @@ -80,6 +81,10 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void { data.attachments = [...data.attachments, ...attachments]; } + if (attributes) { + data.attributes = { ...data.attributes, ...attributes }; + } + data.propagationContext = { ...data.propagationContext, ...propagationContext }; } diff --git a/packages/core/test/lib/spans/captureSpan.test.ts b/packages/core/test/lib/spans/captureSpan.test.ts new file mode 100644 index 000000000000..bfb3d5fd3341 --- /dev/null +++ b/packages/core/test/lib/spans/captureSpan.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client } from '../../../src'; +import { + getCurrentScope, + getGlobalScope, + Scope, + SentrySpan, + setCapturedScopesOnSpan, + setCurrentClient, + withStreamSpan, +} from '../../../src'; +import { captureSpan } from '../../../src/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('captureSpan', () => { + let client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + environment: 'staging', + release: '1.1.1', + }), + ); + + const currentScope = new Scope(); + const isolationScope = new Scope(); + + const enqueueSpanCallback = vi.fn(); + + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + environment: 'staging', + release: '1.1.1', + }), + ); + client.on('enqueueSpan', enqueueSpanCallback); + client.init(); + setCurrentClient(client as Client); + currentScope.clear(); + isolationScope.clear(); + getGlobalScope().clear(); + currentScope.setClient(client as Client); + isolationScope.setClient(client as Client); + vi.clearAllMocks(); + }); + + it("doesn't enqueue a span if no client is set", () => { + getCurrentScope().setClient(undefined); + const span = new SentrySpan({ name: 'spanName' }); + + captureSpan(span); + + expect(enqueueSpanCallback).not.toHaveBeenCalled(); + }); + + it('applies attributes from client and scopess to all spans', () => { + client.getOptions()._metadata = { + sdk: { + name: 'sentry.javascript.browser', + version: '1.0.0', + }, + }; + const span = new SentrySpan({ name: 'spanName' }); + + span.setAttribute('span_attr', 0); + + const segmentSpan = new SentrySpan({ name: 'segmentSpanName' }); + + span.addLink({ context: segmentSpan.spanContext(), attributes: { 'sentry.link.type': 'my_link' } }); + + // @ts-expect-error - this field part of the public contract + span._sentryRootSpan = segmentSpan; + + currentScope.setAttribute('current_scope_attr', 1); + isolationScope.setAttribute('isolation_scope_attr', { value: 2, unit: 'day' }); + getGlobalScope().setAttribute('global_scope_attr', { value: 3 }); + + // this should NOT be applied to `span` because it's not a segment span + currentScope.setContext('os', { name: 'os1' }); + + setCapturedScopesOnSpan(span, currentScope, isolationScope); + + captureSpan(span, client); + + expect(enqueueSpanCallback).toHaveBeenCalledOnce(); + expect(enqueueSpanCallback).toHaveBeenCalledWith({ + _segmentSpan: segmentSpan, // <-- we need this reference to the segment span later on + attributes: { + 'sentry.environment': { + type: 'string', + value: 'staging', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.release': { + type: 'string', + value: '1.1.1', + }, + 'sentry.segment.id': { + type: 'string', + value: segmentSpan.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'segmentSpanName', + }, + span_attr: { + type: 'integer', + value: 0, + }, + current_scope_attr: { + type: 'integer', + value: 1, + }, + isolation_scope_attr: { + type: 'integer', + value: 2, + unit: 'day', + }, + global_scope_attr: { + type: 'integer', + value: 3, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: '1.0.0', + }, + }, + end_timestamp: expect.any(Number), + start_timestamp: expect.any(Number), + is_segment: false, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'my_link', + }, + }, + sampled: false, + span_id: segmentSpan.spanContext().spanId, + trace_id: segmentSpan.spanContext().traceId, + }, + ], + name: 'spanName', + parent_span_id: undefined, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + status: 'ok', + }); + }); + + it('applies scope data to a segment span', () => { + const span = new SentrySpan({ name: 'spanName' }); // if I don't set a segment explicitly, it will be a segment span + + getGlobalScope().setContext('os', { name: 'os3' }); + isolationScope.setContext('app', { name: 'myApp' }); + currentScope.setContext('os', { name: 'os1' }); + + setCapturedScopesOnSpan(span, currentScope, isolationScope); + + captureSpan(span, client); + + expect(enqueueSpanCallback).toHaveBeenCalledOnce(); + expect(enqueueSpanCallback).toHaveBeenCalledWith({ + _segmentSpan: span, + is_segment: true, + attributes: { + 'sentry.release': { + type: 'string', + value: '1.1.1', + }, + 'sentry.segment.id': { + type: 'string', + value: span.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'spanName', + }, + 'sentry.environment': { + type: 'string', + value: 'staging', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'app.name': { + type: 'string', + value: 'myApp', + }, + 'os.name': { + type: 'string', + value: 'os1', + }, + }, + end_timestamp: expect.any(Number), + start_timestamp: expect.any(Number), + name: 'spanName', + parent_span_id: undefined, + span_id: span.spanContext().spanId, + trace_id: span.spanContext().traceId, + links: undefined, + status: 'ok', + }); + }); + + it('applies the beforeSendSpan callback to the span', () => { + client.getOptions().beforeSendSpan = withStreamSpan(span => { + return { + ...span, + attributes: { + ...span.attributes, + attribute_from_beforeSendSpan: { + type: 'string', + value: 'value_from_beforeSendSpan', + }, + }, + }; + }); + const span = new SentrySpan({ name: 'spanName' }); + + span.setAttribute('span_attr', 0); + + const segmentSpan = new SentrySpan({ name: 'segmentSpanName' }); + + // @ts-expect-error - this field part of the public contract + span._sentryRootSpan = segmentSpan; + + currentScope.setAttribute('current_scope_attr', 1); + isolationScope.setAttribute('isolation_scope_attr', { value: 2, unit: 'day' }); + getGlobalScope().setAttribute('global_scope_attr', { value: 3 }); + + setCapturedScopesOnSpan(span, currentScope, isolationScope); + + captureSpan(span, client); + + expect(enqueueSpanCallback).toHaveBeenCalledOnce(); + expect(enqueueSpanCallback).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + attribute_from_beforeSendSpan: { + type: 'string', + value: 'value_from_beforeSendSpan', + }, + }), + }), + ); + }); + + it('applies user data iff sendDefaultPii is true and userdata is set', () => { + client.getOptions().sendDefaultPii = true; + currentScope.setUser({ id: '123', email: 'user@example.com', username: 'testuser' }); + + const span = new SentrySpan({ name: 'spanName' }); + setCapturedScopesOnSpan(span, currentScope, isolationScope); + + captureSpan(span, client); + + expect(enqueueSpanCallback).toHaveBeenCalledOnce(); + expect(enqueueSpanCallback).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'user.id': expect.objectContaining({ + type: 'string', + value: '123', + }), + 'user.email': expect.objectContaining({ + type: 'string', + value: 'user@example.com', + }), + 'user.name': expect.objectContaining({ + type: 'string', + value: 'testuser', + }), + }), + }), + ); + }); + + it("doesn't apply user data if sendDefaultPii is not set and userdata is available", () => { + currentScope.setUser({ id: '123', email: 'user@example.com', username: 'testuser' }); + + const span = new SentrySpan({ name: 'spanName' }); + setCapturedScopesOnSpan(span, currentScope, isolationScope); + + captureSpan(span, client); + + expect(enqueueSpanCallback).toHaveBeenCalledOnce(); + expect(enqueueSpanCallback).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'staging', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.release': { + type: 'string', + value: '1.1.1', + }, + 'sentry.segment.id': { + type: 'string', + value: span.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'spanName', + }, + }, + }), + ); + }); +}); diff --git a/packages/core/test/lib/spans/spanFirstUtils.test.ts b/packages/core/test/lib/spans/spanFirstUtils.test.ts new file mode 100644 index 000000000000..bf534f166228 --- /dev/null +++ b/packages/core/test/lib/spans/spanFirstUtils.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import type { SpanV2JSON } from '../../../src'; +import { safeSetSpanJSONAttributes, SentrySpan, spanToV2JSON } from '../../../src'; +import { applyBeforeSendSpanCallback, contextsToAttributes } from '../../../src/spans/spanFirstUtils'; + +describe('safeSetSpanJSONAttributes', () => { + it('only sets attributes that are not already set', () => { + const span = new SentrySpan({ attributes: { 'app.name': 'original' }, name: 'spanName' }); + const spanJson = spanToV2JSON(span); + + const newAttributes = { 'app.name': 'new', 'app.version': '1.0.0' }; + safeSetSpanJSONAttributes(spanJson, newAttributes); + + expect(spanJson.attributes).toStrictEqual({ + 'app.name': { type: 'string', value: 'original' }, + 'app.version': { type: 'string', value: '1.0.0' }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + }); + }); + + it('creates an attributes object on the span if it does not exist', () => { + const span = new SentrySpan({ name: 'spanName' }); + const spanJson = spanToV2JSON(span); + spanJson.attributes = undefined; + + const newAttributes = { 'app.name': 'new', 'app.version': '1.0.0' }; + safeSetSpanJSONAttributes(spanJson, newAttributes); + expect(spanJson.attributes).toStrictEqual({ + 'app.name': { type: 'string', value: 'new' }, + 'app.version': { type: 'string', value: '1.0.0' }, + }); + }); + + it('sets attribute objects with units', () => { + const span = new SentrySpan({ name: 'spanName' }); + const spanJson = spanToV2JSON(span); + const newAttributes = { 'app.name': { value: 'new', unit: 'ms' }, 'app.version': '1.0.0' }; + safeSetSpanJSONAttributes(spanJson, newAttributes); + expect(spanJson.attributes).toStrictEqual({ + 'app.name': { type: 'string', value: 'new', unit: 'ms' }, + 'app.version': { type: 'string', value: '1.0.0' }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + }); + }); + + it('ignores attribute values other than primitives, arrays and attribute objects', () => { + const span = new SentrySpan({ name: 'spanName' }); + const spanJson = spanToV2JSON(span); + const newAttributes = { foo: { bar: 'baz' } }; + safeSetSpanJSONAttributes(spanJson, newAttributes); + expect(spanJson.attributes).toStrictEqual({ + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + }); + }); +}); + +describe('applyBeforeSendSpanCallback', () => { + it('updates the span if the beforeSendSpan callback returns a new span', () => { + const span = new SentrySpan({ name: 'originalName' }); + const spanJson = spanToV2JSON(span); + const beforeSendSpan = (_span: SpanV2JSON) => { + return { ...spanJson, name: 'newName' }; + }; + const result = applyBeforeSendSpanCallback(spanJson, beforeSendSpan); + expect(result.name).toBe('newName'); + }); + it('returns the span if the beforeSendSpan callback returns undefined', () => { + const span = new SentrySpan({ name: 'spanName' }); + const spanJson = spanToV2JSON(span); + const beforeSendSpan = (_span: SpanV2JSON) => { + return undefined; + }; + // @ts-expect-error - types don't allow undefined by design but we still test against it + const result = applyBeforeSendSpanCallback(spanJson, beforeSendSpan); + expect(result).toBe(spanJson); + }); +}); + +describe('_contextsToAttributes', () => { + it('converts context values that are primitives to attributes', () => { + const contexts = { + app: { app_name: 'test', app_version: '1.0.0' }, + }; + const attributes = contextsToAttributes(contexts); + expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' }); + }); + + it('ignores non-primitive context values', () => { + const contexts = { + app: { app_name: 'test', app_version: '1.0.0', app_metadata: { whatever: 'whenever' } }, + someContext: { someValue: 'test', arrValue: [1, 2, 3] }, + objContext: { objValue: { a: 1, b: 2 } }, + }; + const attributes = contextsToAttributes(contexts); + expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' }); + }); + + it('ignores unknown contexts', () => { + const contexts = { + app: { app_name: 'test', app_version: '1.0.0' }, + unknownContext: { unknownValue: 'test' }, + }; + const attributes = contextsToAttributes(contexts); + expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' }); + }); + + it('converts explicitly mapped context values to attributes', () => { + const contexts = { + os: { build: '1032' }, + app: { + app_name: 'test', + app_version: '1.0.0', + app_identifier: 'com.example.app', + build_type: 'minified', + app_memory: 1024, + app_start_time: '2021-01-01T00:00:00Z', + }, + culture: undefined, + device: { + name: undefined, + }, + someContext: { someValue: 'test', arrValue: [1, 2, 3] }, + objContext: { objValue: { a: 1, b: 2 } }, + }; + const attributes = contextsToAttributes(contexts); + expect(attributes).toStrictEqual({ + 'os.build_id': '1032', + 'app.name': 'test', + 'app.version': '1.0.0', + 'app.identifier': 'com.example.app', + 'app.build_type': 'minified', + 'app.memory': 1024, + 'app.start_time': '2021-01-01T00:00:00Z', + }); + }); + + it("doesn't modify the original contexts object", () => { + // tests that we actually deep-copy the individual contexts so that we can filter and delete keys as needed + const contexts = { + app: { app_name: 'test', app_version: '1.0.0' }, + }; + const attributes = contextsToAttributes(contexts); + expect(attributes).toStrictEqual({ 'app.name': 'test', 'app.version': '1.0.0' }); + expect(contexts).toStrictEqual({ app: { app_name: 'test', app_version: '1.0.0' } }); + }); +}); diff --git a/packages/opentelemetry/src/streamingSpanExporter.ts b/packages/opentelemetry/src/streamingSpanExporter.ts index 707683a8eb25..1247d2e0d0d7 100644 --- a/packages/opentelemetry/src/streamingSpanExporter.ts +++ b/packages/opentelemetry/src/streamingSpanExporter.ts @@ -57,7 +57,7 @@ export class StreamingSpanExporter implements ISentrySpanExporter { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, ...data, }; - safeSetSpanJSONAttributes(spanJSON, allData, spanJSON.attributes); + safeSetSpanJSONAttributes(spanJSON, allData); spanJSON.name = description; }); From 1b26b1cd7a2ffb10af946b4f0face5c575fa26d7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 12 Dec 2025 11:52:44 +0100 Subject: [PATCH 43/58] add another test for scope attribute precedence over contexts --- .../core/test/lib/spans/captureSpan.test.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/core/test/lib/spans/captureSpan.test.ts b/packages/core/test/lib/spans/captureSpan.test.ts index bfb3d5fd3341..c790c23be851 100644 --- a/packages/core/test/lib/spans/captureSpan.test.ts +++ b/packages/core/test/lib/spans/captureSpan.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, test, vi } from 'vitest'; import type { Client } from '../../../src'; import { getCurrentScope, @@ -322,4 +322,51 @@ describe('captureSpan', () => { }), ); }); + + test('scope attributes have precedence over attributes derived from contexts', () => { + currentScope.setUser({ id: '123', email: 'user@example.com', username: 'testuser' }); + + const span = new SentrySpan({ name: 'spanName' }); + setCapturedScopesOnSpan(span, currentScope, isolationScope); + + // Aalthough the current scope has precedence over the global scope, + // scope attributes have precedence over context attributes + getGlobalScope().setAttribute('app.name', 'myApp-scope-attribute'); + currentScope.setContext('app', { name: 'myApp-current-scope-context' }); + + captureSpan(span, client); + + expect(enqueueSpanCallback).toHaveBeenCalledOnce(); + expect(enqueueSpanCallback).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'staging', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.release': { + type: 'string', + value: '1.1.1', + }, + 'sentry.segment.id': { + type: 'string', + value: span.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'spanName', + }, + // Therefore, we expect the attribute to be taken from the global scope's attributes + 'app.name': { + type: 'string', + value: 'myApp-scope-attribute', + }, + }, + }), + ); + }); }); From f87e9d3ec3a4cc8cac2cb0dc2924c36c3e2d29a1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 12 Dec 2025 11:54:14 +0100 Subject: [PATCH 44/58] Potential fix for code scanning alert no. 424: Missing regular expression anchor Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../suites/span-first/web-vitals/web-vitals-ttfb/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts index a54f4c1bdb24..2480874a07d4 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts @@ -27,7 +27,7 @@ sentryTest('captures TTFB web vital', async ({ getLocalTestUrl, page }) => { } expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb.requestTime']).toEqual({ - type: expect.stringMatching(/^integer|double$/), + type: expect.stringMatching(/^(integer|double)$/), value: expect.any(Number), }); }); From 1ec94ddec83afc51ad1a8c5d546193d8176b861e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 12 Dec 2025 11:58:55 +0100 Subject: [PATCH 45/58] fix lint errors --- packages/browser/src/integrations/spanstreaming.ts | 6 +++--- packages/core/src/spans/captureSpan.ts | 1 - packages/opentelemetry/src/spanProcessor.ts | 1 - packages/opentelemetry/src/streamingSpanExporter.ts | 5 ++--- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index e1d215d8c29d..02f91fee02ac 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -55,12 +55,12 @@ export const spanStreamingIntegration = defineIntegration(((userOptions?: Partia } client.on('enqueueSpan', spanJSON => { - const spanTreeMapKey = getSpanTreeMapKey(spanJSON as SpanV2JSONWithSegmentRef); + const spanTreeMapKey = getSpanTreeMapKey(spanJSON); const spanBuffer = spanTreeMap.get(spanTreeMapKey); if (spanBuffer) { - spanBuffer.add(spanJSON as SpanV2JSONWithSegmentRef); + spanBuffer.add(spanJSON); } else { - spanTreeMap.set(spanTreeMapKey, new Set([spanJSON as SpanV2JSONWithSegmentRef])); + spanTreeMap.set(spanTreeMapKey, new Set([spanJSON])); } }); diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index 5ac3826c42e9..bc87e4fe6fe7 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -15,7 +15,6 @@ import { SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../semanticAttributes'; import { getCapturedScopesOnSpan } from '../tracing/utils'; -import type { SerializedAttributes } from '../types-hoist/attributes'; import type { Span, SpanV2JSON } from '../types-hoist/span'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { isV2BeforeSendSpanCallback } from '../utils/beforeSendSpan'; diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index 2feea6cd6e7e..ad19dd0c5a4f 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -4,7 +4,6 @@ import type { ReadableSpan, Span, SpanProcessor as SpanProcessorInterface } from import type { Client } from '@sentry/core'; import { addChildSpanToSpan, - captureSpan, getClient, getDefaultCurrentScope, getDefaultIsolationScope, diff --git a/packages/opentelemetry/src/streamingSpanExporter.ts b/packages/opentelemetry/src/streamingSpanExporter.ts index 1247d2e0d0d7..145d34e8dc3b 100644 --- a/packages/opentelemetry/src/streamingSpanExporter.ts +++ b/packages/opentelemetry/src/streamingSpanExporter.ts @@ -1,7 +1,6 @@ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import type { Client, Span, SpanV2JSON } from '@sentry/core'; +import type { Client, Span, SpanV2JSON, SpanV2JSONWithSegmentRef } from '@sentry/core'; import { - type SpanV2JSONWithSegmentRef, captureSpan, createSpanV2Envelope, debug, @@ -11,7 +10,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; -import { type ISentrySpanExporter, getSpanData } from './spanExporter'; +import { getSpanData, type ISentrySpanExporter } from './spanExporter'; type StreamingSpanExporterOptions = { flushInterval?: number; From a655a85fbdd9e5f6df872005189002db677623c3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 12 Dec 2025 12:46:13 +0100 Subject: [PATCH 46/58] skip integration tests for cdn bundles --- .../suites/span-first/backgroundtab-pageload/test.ts | 6 ++++-- .../suites/span-first/error/test.ts | 10 ++++------ .../suites/span-first/linked-traces/test.ts | 8 +++++--- .../suites/span-first/pageload/test.ts | 5 +++-- .../span-first/web-vitals/web-vitals-ttfb/test.ts | 5 +++-- .../browser-integration-tests/utils/helpers.ts | 8 ++++++++ 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts index 368820e754fb..2f7a3a85303d 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/backgroundtab-pageload/test.ts @@ -1,12 +1,14 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; -import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers'; import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; sentryTest('ends pageload span when the page goes to background', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { + // for now, spanStreamingIntegration is only exported in the NPM package, so we skip the test for bundles. + if (shouldSkipTracingTest() || testingCdnBundle()) { sentryTest.skip(); } + const url = await getLocalTestUrl({ testDir: __dirname }); const spanPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); diff --git a/dev-packages/browser-integration-tests/suites/span-first/error/test.ts b/dev-packages/browser-integration-tests/suites/span-first/error/test.ts index 682cece57172..1112e2b461da 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/error/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/error/test.ts @@ -5,6 +5,7 @@ import { envelopeRequestParser, runScriptInSandbox, shouldSkipTracingTest, + testingCdnBundle, waitForErrorRequest, } from '../../../utils/helpers'; import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; @@ -12,12 +13,9 @@ import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; sentryTest( 'puts the pageload span name onto an error event caught during pageload', async ({ getLocalTestUrl, page, browserName }) => { - if (browserName === 'webkit') { - // This test fails on Webkit as errors thrown from `runScriptInSandbox` are Script Errors and skipped by Sentry - sentryTest.skip(); - } - - if (shouldSkipTracingTest()) { + // for now, spanStreamingIntegration is only exported in the NPM package, so we skip the test for bundles. + // This test fails on Webkit as errors thrown from `runScriptInSandbox` are Script Errors and skipped by Sentry + if (shouldSkipTracingTest() || testingCdnBundle() || browserName === 'webkit') { sentryTest.skip(); } diff --git a/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts index 4709fd6ae81c..5215c4125911 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/linked-traces/test.ts @@ -1,11 +1,12 @@ import { expect } from '@playwright/test'; import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; -import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers'; import { getSpanOp, waitForV2Spans } from '../../../utils/spanFirstUtils'; sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { + // for now, spanStreamingIntegration is only exported in the NPM package, so we skip the test for bundles. + if (shouldSkipTracingTest() || testingCdnBundle()) { sentryTest.skip(); } @@ -77,7 +78,8 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ }); sentryTest("doesn't link between hard page reloads by default", async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { + // for now, spanStreamingIntegration is only exported in the NPM package, so we skip the test for bundles. + if (shouldSkipTracingTest() || testingCdnBundle()) { sentryTest.skip(); } diff --git a/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts index e488d96bc9e6..350a10dded7d 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/pageload/test.ts @@ -1,10 +1,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; -import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers'; import { getSpanOp, waitForSpanV2Envelope } from '../../../utils/spanFirstUtils'; sentryTest('sends a span v2 envelope for the pageload', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { + // for now, spanStreamingIntegration is only exported in the NPM package, so we skip the test for bundles. + if (shouldSkipTracingTest() || testingCdnBundle()) { sentryTest.skip(); } diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts index 2480874a07d4..3a527a552990 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts @@ -1,10 +1,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; -import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; import { getSpanOp, waitForV2Spans } from '../../../../utils/spanFirstUtils'; sentryTest('captures TTFB web vital', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { + // for now, spanStreamingIntegration is only exported in the NPM package, so we skip the test for bundles. + if (shouldSkipTracingTest() || testingCdnBundle()) { sentryTest.skip(); } const pageloadSpansPromise = waitForV2Spans(page, spans => !!spans.find(span => getSpanOp(span) === 'pageload')); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 0495a539ff53..2ee6ad83fd88 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -314,6 +314,14 @@ export function shouldSkipTracingTest(): boolean { return bundle != null && !bundle.includes('tracing') && !bundle.includes('esm') && !bundle.includes('cjs'); } +/** + * @returns `true` if we are testing a CDN bundle + */ +export function testingCdnBundle(): boolean { + const bundle = process.env.PW_BUNDLE; + return bundle != null; +} + /** * Today we always run feedback tests, but this can be used to guard this if we ever need to. */ From 0138ff65e15fac7cf8e6d204bfba43182c1d425e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 12 Dec 2025 12:53:54 +0100 Subject: [PATCH 47/58] fix skip tests not for esm/cjs npm package exports --- dev-packages/browser-integration-tests/utils/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 2ee6ad83fd88..ad827e5fd0c4 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -319,7 +319,7 @@ export function shouldSkipTracingTest(): boolean { */ export function testingCdnBundle(): boolean { const bundle = process.env.PW_BUNDLE; - return bundle != null; + return bundle != null && (bundle.startsWith('bundle') || bundle.startsWith('loader')); } /** From 7b16758db5133c1015d5469e802939d50c1d2459 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 12 Dec 2025 15:08:53 +0100 Subject: [PATCH 48/58] fix missing exports of `withStreamSpan` --- packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/remix/src/server/index.ts | 1 + packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + 7 files changed, 7 insertions(+) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index d5cfed7fae7d..c9b30f4bcf13 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -165,6 +165,7 @@ export { unleashIntegration, growthbookIntegration, metrics, + withStreamSpan, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index b9ab9b013925..4ae6745bd517 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -151,6 +151,7 @@ export { unleashIntegration, growthbookIntegration, metrics, + withStreamSpan, } from '@sentry/node'; export { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 835105527b1e..c436db13a4db 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -170,6 +170,7 @@ export { statsigIntegration, unleashIntegration, metrics, + withStreamSpan, } from '@sentry/node'; export { diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 72fd0fa3a12d..e33be85afb4e 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -151,6 +151,7 @@ export { statsigIntegration, unleashIntegration, metrics, + withStreamSpan, } from '@sentry/node'; export { diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 181c9fd36d16..24e816fb081f 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -127,6 +127,7 @@ export { consoleLoggingIntegration, createConsolaReporter, createSentryWinstonTransport, + withStreamSpan, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 6e2bc1cb9f61..4847d38a1a05 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -130,6 +130,7 @@ export { consoleLoggingIntegration, createConsolaReporter, createSentryWinstonTransport, + withStreamSpan, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 47c4cfa7d3f8..56ccbb3a774e 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -131,6 +131,7 @@ export { createSentryWinstonTransport, vercelAIIntegration, metrics, + withStreamSpan, } from '@sentry/node'; // We can still leave this for the carrier init and type exports From 5f0b92ecf59a48f50967a319cd0cc31aaae84436 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 15 Dec 2025 14:52:55 +0100 Subject: [PATCH 49/58] apply request data to segment span --- packages/core/src/client.ts | 14 ++- packages/core/src/integrations/requestdata.ts | 89 +++++++++++++++++-- packages/core/src/semanticAttributes.ts | 1 + packages/core/src/spans/captureSpan.ts | 1 + 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 1cacceeba007..d2af0d6dd49d 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -8,7 +8,7 @@ import type { IntegrationIndex } from './integration'; import { afterSetupIntegrations, setupIntegration, setupIntegrations } from './integration'; import { _INTERNAL_flushLogsBuffer } from './logs/internal'; import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; -import type { Scope } from './scope'; +import type { Scope, ScopeData } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; @@ -629,10 +629,16 @@ export abstract class Client { */ public on(hook: 'enqueueSpan', callback: (spanJSON: SpanV2JSONWithSegmentRef) => void): () => void; /** - * Register a callback for when a span JSON is processed, to add some attributes to the span JSON. + * Register a callback for when a span JSON is processed, to add some data to the span JSON. */ public on(hook: 'processSpan', callback: (spanJSON: SpanV2JSON, hint: { readOnlySpan: Span }) => 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: (spanJSON: SpanV2JSON, hint: { scopeData: ScopeData }) => 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. @@ -910,6 +916,8 @@ export abstract class Client { public emit(hook: 'afterSpanEnd', span: Span): void; /** Fire a hook after a span is processed, to add some attributes to the span JSON. */ public emit(hook: 'processSpan', spanJSON: SpanV2JSON, hint: { readOnlySpan: Span }): void; + /** Fire a hook after a span is processed, to add some attributes to the span JSON. */ + public emit(hook: 'processSegmentSpan', spanJSON: SpanV2JSON, hint: { scopeData: ScopeData }): void; /** Fire a hook after the `segmentSpanEnd` hook is fired. */ public emit(hook: 'afterSegmentSpanEnd', span: Span): void; /** Fire a hook after a span ready to be enqueued into the span buffer. */ diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 5a45bc9c9861..dc315adb3575 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,8 +1,19 @@ +import type { Client } from '../client'; import { defineIntegration } from '../integration'; +import { + SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, + SEMANTIC_ATTRIBUTE_URL_FULL, + SEMANTIC_ATTRIBUTE_URL_QUERY, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, +} from '../semanticAttributes'; +import { safeSetSpanJSONAttributes } from '../spans/spanFirstUtils'; import type { Event } from '../types-hoist/event'; import type { IntegrationFn } from '../types-hoist/integration'; +import type { ClientOptions } from '../types-hoist/options'; import type { RequestEventData } from '../types-hoist/request'; +import type { BaseTransportOptions } from '../types-hoist/transport'; import { parseCookie } from '../utils/cookie'; +import { httpHeadersToSpanAttributes } from '../utils/request'; import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; interface RequestDataIncludeOptions { @@ -40,16 +51,67 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return { name: INTEGRATION_NAME, + setup(client) { + client.on('processSegmentSpan', (spanJSON, { scopeData }) => { + const { sdkProcessingMetadata = {} } = scopeData; + const { normalizedRequest, ipAddress } = sdkProcessingMetadata; + + if (!normalizedRequest) { + return; + } + + const includeWithDefaultPiiApplied: RequestDataIncludeOptions = getIncludeWithDefaultPiiApplied( + include, + client, + ); + + // no need to check for include after calling `extractNormalizedRequestData` + // because it already internally only return what's permitted by `include` + const { method, url, query_string, headers, data, env } = extractNormalizedRequestData( + normalizedRequest, + includeWithDefaultPiiApplied, + ); + + safeSetSpanJSONAttributes(spanJSON, { + ...(method ? { [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: method } : {}), + ...(url ? { [SEMANTIC_ATTRIBUTE_URL_FULL]: url } : {}), + ...(query_string ? { [SEMANTIC_ATTRIBUTE_URL_QUERY]: query_string } : {}), + ...(headers ? httpHeadersToSpanAttributes(headers, client.getOptions().sendDefaultPii) : {}), + // TODO: Apparently, Relay still needs Pii rule updates, so I'm leaving this out for now + // ...(cookies + // ? Object.keys(cookies).reduce( + // (acc, cookieName) => ({ + // ...acc, + // [`http.request.header.cookie.${cookieName}`]: cookies[cookieName] ?? '', + // }), + // {} as Record, + // ) + // : {}), + ...(include.ip + ? { + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: + (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress, + } + : {}), + ...(data ? { 'http.request.body.content': data } : {}), + ...(env + ? { + 'http.request.env': Object.keys(env).reduce( + (acc, key) => ({ ...acc, [key]: env[key] ?? '' }), + {} as Record, + ), + } + : {}), + }); + }); + }, // TODO (span-streaming): probably fine to leave as-is for errors. // For spans, we go through global context -> attribute conversion or omit this completely (TBD) processEvent(event, _hint, client) { const { sdkProcessingMetadata = {} } = event; const { normalizedRequest, ipAddress } = sdkProcessingMetadata; - const includeWithDefaultPiiApplied: RequestDataIncludeOptions = { - ...include, - ip: include.ip ?? client.getOptions().sendDefaultPii, - }; + const includeWithDefaultPiiApplied: RequestDataIncludeOptions = getIncludeWithDefaultPiiApplied(include, client); if (normalizedRequest) { addNormalizedRequestDataToEvent(event, normalizedRequest, { ipAddress }, includeWithDefaultPiiApplied); @@ -66,6 +128,21 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = */ export const requestDataIntegration = defineIntegration(_requestDataIntegration); +const getIncludeWithDefaultPiiApplied = ( + include: { + cookies?: boolean; + data?: boolean; + headers?: boolean; + ip?: boolean; + query_string?: boolean; + url?: boolean; + }, + client: Client>, +): RequestDataIncludeOptions => ({ + ...include, + ip: include.ip ?? client.getOptions().sendDefaultPii, +}); + /** * Add already normalized request data to an event. * This mutates the passed in event. @@ -105,14 +182,14 @@ function extractNormalizedRequestData( // Remove the Cookie header in case cookie data should not be included in the event if (!include.cookies) { - delete (headers as { cookie?: string }).cookie; + delete headers.cookie; } // Remove IP headers in case IP data should not be included in the event if (!include.ip) { ipHeaderNames.forEach(ipHeaderName => { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (headers as Record)[ipHeaderName]; + delete headers[ipHeaderName]; }); } } diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index ac7bc2c3b188..f41e2a63acdb 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -65,6 +65,7 @@ export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size'; /** TODO: Remove these once we update to latest semantic conventions */ export const SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD = 'http.request.method'; export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; +export const SEMANTIC_ATTRIBUTE_URL_QUERY = 'url.query'; /** * A span link attribute to mark the link as a special span link. diff --git a/packages/core/src/spans/captureSpan.ts b/packages/core/src/spans/captureSpan.ts index bc87e4fe6fe7..b31687696c5e 100644 --- a/packages/core/src/spans/captureSpan.ts +++ b/packages/core/src/spans/captureSpan.ts @@ -47,6 +47,7 @@ export function captureSpan(span: Span, client = getClient()): void { if (span === segmentSpan) { applyScopeToSegmentSpan(spanJSON, finalScopeData); + client.emit('processSegmentSpan', spanJSON, { scopeData: finalScopeData }); } // Allow integrations to add additional data to the span JSON From e1f4eda93e9c6a8ea54977162d09866fea7387a2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 15 Dec 2025 15:07:42 +0100 Subject: [PATCH 50/58] register a bunch of known contexts --- packages/core/src/spans/spanFirstUtils.ts | 42 ++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/core/src/spans/spanFirstUtils.ts b/packages/core/src/spans/spanFirstUtils.ts index 7cad2045b42a..209689ac0b29 100644 --- a/packages/core/src/spans/spanFirstUtils.ts +++ b/packages/core/src/spans/spanFirstUtils.ts @@ -80,7 +80,47 @@ const explicitAttributeToContextMapping = { 'app.start_time': 'app.app_start_time', }; -const knownContexts = ['app', 'os', 'device', 'culture', 'cloud_resource', 'runtime']; +const knownContexts = [ + // set by `nodeContextIntegration` + 'app', + 'os', + 'device', + 'culture', + 'cloud_resource', + 'runtime', + + // TODO: These need more thorough checking if they're all setting expected attributes + + // set by the `instrumentPostgresJs` + 'postgresjsConnection', + // set by `ensureIsWrapped` + 'missing_instrumentation', + // set by `nodeProfilingIntegration` + 'profile', + // set by angular `init` + 'angular', + // set by AWS Lambda SDK + 'aws.lambda', + 'aws.cloudwatch.logs', + // set by `instrumentBunServe` + 'response', + // set by `trpcMiddleware` + 'trpc', + // set by `instrumentSupabaseClient` + 'supabase', + // set by `gcp.function.context` + 'gcp.function.context', + // set by nextjs SDK + 'nextjs', + // set by react SDK `captureReactException`, `init` + 'react', + // set by react SDK `createReduxEnhancer` + 'state', + // set by `replayIntegration` + 'Replays', + // set by feature flags integration(s) + 'flags', +]; /** * Converts a context object to a set of attributes. From 6c1eda2df0f352b662c9dceb4f8e4bbb1ae3c333 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 15 Dec 2025 15:50:13 +0100 Subject: [PATCH 51/58] use new attribute serialization for spanv2 --- packages/core/src/attributes.ts | 2 +- packages/core/src/spans/spanFirstUtils.ts | 11 +-- packages/core/src/types-hoist/span.ts | 6 +- packages/core/src/utils/spanUtils.ts | 9 +- .../core/test/lib/utils/attributes.test.ts | 99 ------------------- 5 files changed, 14 insertions(+), 113 deletions(-) delete mode 100644 packages/core/test/lib/utils/attributes.test.ts diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 0ed74a31ad67..e4663e87a047 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -6,7 +6,7 @@ export type RawAttributes = T & ValidatedAttributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeObject : T; -export type Attributes = Record; +export type TypedAttributes = Record; export type AttributeValueType = string | number | boolean | Array | Array | Array; diff --git a/packages/core/src/spans/spanFirstUtils.ts b/packages/core/src/spans/spanFirstUtils.ts index 209689ac0b29..9088282c2953 100644 --- a/packages/core/src/spans/spanFirstUtils.ts +++ b/packages/core/src/spans/spanFirstUtils.ts @@ -1,8 +1,7 @@ import type { RawAttributes } from '../attributes'; -import { isAttributeObject } from '../attributes'; +import { attributeValueToTypedAttributeValue, isAttributeObject } from '../attributes'; import type { Context, Contexts } from '../types-hoist/context'; import type { SpanV2JSON } from '../types-hoist/span'; -import { attributeValueToSerializedAttribute } from '../utils/attributes'; import { isPrimitive } from '../utils/is'; import { showSpanDropWarning } from '../utils/spanUtils'; @@ -56,13 +55,13 @@ function setAttributeOnSpanJSONWithMaybeUnit( const { value, unit } = attributeValue; if (isSupportedSerializableType(value)) { - spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(value); + spanJSON.attributes[attributeKey] = attributeValueToTypedAttributeValue(attributeValue); if (unit) { spanJSON.attributes[attributeKey].unit = unit; } } } else if (isSupportedSerializableType(attributeValue)) { - spanJSON.attributes[attributeKey] = attributeValueToSerializedAttribute(attributeValue); + spanJSON.attributes[attributeKey] = attributeValueToTypedAttributeValue(attributeValue); } } @@ -118,8 +117,8 @@ const knownContexts = [ 'state', // set by `replayIntegration` 'Replays', - // set by feature flags integration(s) - 'flags', + // Other information: + // - no need to handler feature flags `flags` context because it's already added to the active span ]; /** diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index 76e11072af3d..91f857922449 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,4 +1,4 @@ -import type { SerializedAttributes } from './attributes'; +import type { TypedAttributes } from '../attributes'; import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; @@ -47,8 +47,8 @@ export interface SpanV2JSON { end_timestamp: number; status: 'ok' | 'error'; is_segment: boolean; - attributes?: SerializedAttributes; - links?: SpanLinkJSON[]; + attributes?: TypedAttributes; + links?: SpanLinkJSON[]; } /** diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 6f0977411639..f24690ebfcef 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -1,4 +1,5 @@ import { getAsyncContextStrategy } from '../asyncContext'; +import { attributeValueToTypedAttributeValue, TypedAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import { @@ -111,7 +112,7 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] * @param links * @returns */ -export function getV2SpanLinks(links?: SpanLink[]): SpanLinkJSON[] | undefined { +export function getV2SpanLinks(links?: SpanLink[]): SpanLinkJSON[] | undefined { if (links?.length) { return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ span_id: spanId, @@ -320,11 +321,11 @@ export function getV2StatusMessage(status: SpanStatus | undefined): 'ok' | 'erro /** * Convert the attributes to the ones expected by Sentry, including the type annotation */ -export function getV2Attributes(attributes: SpanAttributes): SerializedAttributes { +export function getV2Attributes(attributes: SpanAttributes): TypedAttributes { return Object.entries(attributes).reduce((acc, [key, value]) => { - acc[key] = attributeValueToSerializedAttribute(value); + acc[key] = attributeValueToTypedAttributeValue(value); return acc; - }, {} as SerializedAttributes); + }, {} as TypedAttributes); } const CHILD_SPANS_FIELD = '_sentryChildSpans'; diff --git a/packages/core/test/lib/utils/attributes.test.ts b/packages/core/test/lib/utils/attributes.test.ts deleted file mode 100644 index 9dd05e0e5b28..000000000000 --- a/packages/core/test/lib/utils/attributes.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { attributesFromObject } from '../../../src/utils/attributes'; - -describe('attributesFromObject', () => { - it('flattens an object', () => { - const context = { - a: 1, - b: { c: { d: 2 } }, - }; - - const result = attributesFromObject(context); - - expect(result).toEqual({ - a: 1, - 'b.c.d': 2, - }); - }); - - it('flattens an object with a max depth', () => { - const context = { - a: 1, - b: { c: { d: 2 } }, - }; - - const result = attributesFromObject(context, 2); - - expect(result).toEqual({ - a: 1, - 'b.c': '[Object]', - }); - }); - - it('flattens an object an array', () => { - const context = { - a: 1, - b: { c: { d: 2 } }, - integrations: ['foo', 'bar'], - }; - - const result = attributesFromObject(context); - - expect(result).toEqual({ - a: 1, - 'b.c.d': 2, - integrations: '["foo","bar"]', - }); - }); - - it('handles a circular object', () => { - const context = { - a: 1, - b: { c: { d: 2 } }, - }; - context.b.c.e = context.b; - - const result = attributesFromObject(context, 5); - - expect(result).toEqual({ - a: 1, - 'b.c.d': 2, - 'b.c.e': '[Circular ~]', - }); - }); - - it('handles a circular object in an array', () => { - const context = { - a: 1, - b: { c: { d: 2 } }, - integrations: ['foo', 'bar'], - }; - - // @ts-expect-error - this is fine - context.integrations[0] = context.integrations; - - const result = attributesFromObject(context, 5); - - expect(result).toEqual({ - a: 1, - 'b.c.d': 2, - integrations: '["[Circular ~]","bar"]', - }); - }); - - it('handles objects in arrays', () => { - const context = { - a: 1, - b: { c: { d: 2 } }, - integrations: [{ name: 'foo' }, { name: 'bar' }], - }; - - const result = attributesFromObject(context); - - expect(result).toEqual({ - a: 1, - 'b.c.d': 2, - integrations: '[{"name":"foo"},{"name":"bar"}]', - }); - }); -}); From cef2f3996192ff85c7c93af604052d5eb735f4dd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 15 Dec 2025 15:52:56 +0100 Subject: [PATCH 52/58] remove unused code --- packages/core/src/index.ts | 1 - packages/core/src/utils/attributes.ts | 98 --------------------------- packages/core/src/utils/spanUtils.ts | 5 +- 3 files changed, 2 insertions(+), 102 deletions(-) delete mode 100644 packages/core/src/utils/attributes.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2c19ee5e83c0..b59888c1bdd6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,7 +92,6 @@ export { } from './utils/spanUtils'; export { captureSpan } from './spans/captureSpan'; export { safeSetSpanJSONAttributes } from './spans/spanFirstUtils'; -export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; diff --git a/packages/core/src/utils/attributes.ts b/packages/core/src/utils/attributes.ts deleted file mode 100644 index 99419ce1afbd..000000000000 --- a/packages/core/src/utils/attributes.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { SerializedAttribute } from '../types-hoist/attributes'; -import type { SpanAttributes } from '../types-hoist/span'; -import { normalize } from '../utils/normalize'; - -/** - * Converts an attribute value to a serialized attribute value object, containing - * a type descriptor as well as the value. - * - * TODO: dedupe this with the logs version of the function (didn't do this yet to avoid - * dependance on logs/spans for the open questions RE array and object attribute types) - * - * @param value - The value of the log attribute. - * @returns The serialized log attribute. - */ -export function attributeValueToSerializedAttribute(value: unknown): SerializedAttribute { - switch (typeof value) { - case 'number': - if (Number.isInteger(value)) { - return { - value, - type: 'integer', - }; - } - return { - value, - type: 'double', - }; - case 'boolean': - return { - value, - type: 'boolean', - }; - case 'string': - return { - value, - type: 'string', - }; - default: { - let stringValue = ''; - try { - stringValue = JSON.stringify(value) ?? ''; - } catch { - // Do nothing - } - return { - value: stringValue, - type: 'string', - }; - } - } -} - -/** - * Given an object that might contain keys with primitive, array, or object values, - * return a SpanAttributes object that flattens the object into a single level. - * - Nested keys are separated by '.'. - * - arrays are stringified (TODO: might change, depending on how we support array attributes) - * - objects are flattened - * - primitives are added directly - * - nullish values are ignored - * - maxDepth is the maximum depth to flatten the object to - * - * @param obj - The object to flatten into span attributes - * @returns The span attribute object - */ -export function attributesFromObject(obj: Record, maxDepth = 3): SpanAttributes { - const result: Record = {}; - - function primitiveOrToString(current: unknown): number | boolean | string { - if (typeof current === 'number' || typeof current === 'boolean' || typeof current === 'string') { - return current; - } - return String(current); - } - - function flatten(current: unknown, prefix: string, depth: number): void { - if (current == null) { - return; - } else if (depth >= maxDepth) { - result[prefix] = primitiveOrToString(current); - return; - } else if (Array.isArray(current)) { - result[prefix] = JSON.stringify(current); - } else if (typeof current === 'number' || typeof current === 'string' || typeof current === 'boolean') { - result[prefix] = current; - } else if (typeof current === 'object' && current !== null && !Array.isArray(current) && depth < maxDepth) { - for (const [key, value] of Object.entries(current as Record)) { - flatten(value, prefix ? `${prefix}.${key}` : key, depth + 1); - } - } - } - - const normalizedObj = normalize(obj, maxDepth); - - flatten(normalizedObj, '', 0); - - return result; -} diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index f24690ebfcef..cb7febc8cfb5 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -1,5 +1,6 @@ import { getAsyncContextStrategy } from '../asyncContext'; -import { attributeValueToTypedAttributeValue, TypedAttributes } from '../attributes'; +import type { TypedAttributes } from '../attributes'; +import { attributeValueToTypedAttributeValue } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import { @@ -11,7 +12,6 @@ import { import type { SentrySpan } from '../tracing/sentrySpan'; import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; -import type { SerializedAttributes } from '../types-hoist/attributes'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput, SpanV2JSON } from '../types-hoist/span'; @@ -20,7 +20,6 @@ import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; import { timestampInSeconds } from '../utils/time'; import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing'; -import { attributeValueToSerializedAttribute } from './attributes'; import { consoleSandbox } from './debug-logger'; import { _getSpanForScope } from './spanOnScope'; From 6b39ac7be4bdd96e2ab047bfe5a4607c3a25508f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 14:42:52 +0100 Subject: [PATCH 53/58] flush buffer when `Sentry.flush()` is called --- packages/opentelemetry/src/streamingSpanExporter.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opentelemetry/src/streamingSpanExporter.ts b/packages/opentelemetry/src/streamingSpanExporter.ts index 145d34e8dc3b..30c6b6f69452 100644 --- a/packages/opentelemetry/src/streamingSpanExporter.ts +++ b/packages/opentelemetry/src/streamingSpanExporter.ts @@ -75,6 +75,10 @@ export class StreamingSpanExporter implements ISentrySpanExporter { this._debounceFlushInterval(); } }); + + this._client.on('flush', () => { + this.flush(); + }); } /** From 13c3631d36ee58946defd871e77147996e60cdae Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 17:01:31 +0100 Subject: [PATCH 54/58] extract span buffer to class, add serverSpanStreamingIntegration, use it in cloudflare, vercel edge and deno --- packages/cloudflare/src/sdk.ts | 2 + packages/cloudflare/test/sdk.test.ts | 65 +++++- packages/core/src/client.ts | 2 +- packages/core/src/index.ts | 2 + .../src/integrations/serverSpanStreaming.ts | 54 +++++ packages/core/src/spans/spanBuffer.ts | 126 +++++++++++ .../integrations/serverSpanStreaming.test.ts | 161 ++++++++++++++ .../core/test/lib/spans/spanBuffer.test.ts | 200 ++++++++++++++++++ packages/deno/src/sdk.ts | 4 +- packages/deno/test/sdk.test.ts | 34 ++- .../src/streamingSpanExporter.ts | 126 +---------- packages/vercel-edge/src/sdk.ts | 2 + packages/vercel-edge/test/sdk.test.ts | 42 ++++ 13 files changed, 700 insertions(+), 120 deletions(-) create mode 100644 packages/core/src/integrations/serverSpanStreaming.ts create mode 100644 packages/core/src/spans/spanBuffer.ts create mode 100644 packages/core/test/lib/integrations/serverSpanStreaming.test.ts create mode 100644 packages/core/test/lib/spans/spanBuffer.test.ts create mode 100644 packages/vercel-edge/test/sdk.test.ts diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 238cc13253a5..37277af48615 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -8,6 +8,7 @@ import { initAndBind, linkedErrorsIntegration, requestDataIntegration, + serverSpanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import type { CloudflareClientOptions, CloudflareOptions } from './client'; @@ -36,6 +37,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ // TODO(v11): the `include` object should be defined directly in the integration based on `sendDefaultPii` requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }), consoleIntegration(), + ...(options.traceLifecycle === 'stream' ? [serverSpanStreamingIntegration()] : []), ]; } diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 2f4ec7844559..e3186973ddfe 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,7 +1,7 @@ import * as SentryCore from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, it, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; -import { init } from '../src/sdk'; +import { getDefaultIntegrations, init } from '../src/sdk'; import { resetSdk } from './testUtils'; describe('init', () => { @@ -18,4 +18,65 @@ describe('init', () => { expect(client).toBeDefined(); expect(client).toBeInstanceOf(CloudflareClient); }); + + describe('getDefaultIntegrations', () => { + it('returns list of integrations with default options', () => { + const integrations = getDefaultIntegrations({}).map(integration => integration.name); + expect(integrations).toEqual([ + 'Dedupe', + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'Fetch', + 'Hono', + 'RequestData', + 'Console', + ]); + }); + + it('adds dedupeIntegration if enableDedupe is true', () => { + const integrations = getDefaultIntegrations({ enableDedupe: true }).map(integration => integration.name); + expect(integrations).toEqual([ + 'Dedupe', + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'Fetch', + 'Hono', + 'RequestData', + 'Console', + ]); + }); + + it('adds serverSpanStreamingIntegration if traceLifecycle is stream', () => { + const integrations = getDefaultIntegrations({ traceLifecycle: 'stream' }).map(integration => integration.name); + expect(integrations).toEqual([ + 'Dedupe', + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'Fetch', + 'Hono', + 'RequestData', + 'Console', + 'ServerSpanStreaming', + ]); + }); + + it('intializes requestDataIntegration to not include cookies if sendDefaultPii is false', () => { + const reqDataIntegrationSpy = vi.spyOn(SentryCore, 'requestDataIntegration'); + + getDefaultIntegrations({ sendDefaultPii: false }).map(integration => integration.name); + + expect(reqDataIntegrationSpy).toHaveBeenCalledWith({ include: { cookies: false } }); + }); + + it('intializes requestDataIntegration to include cookies if sendDefaultPii is true', () => { + const reqDataIntegrationSpy = vi.spyOn(SentryCore, 'requestDataIntegration'); + + getDefaultIntegrations({ sendDefaultPii: true }).map(integration => integration.name); + + expect(reqDataIntegrationSpy).toHaveBeenCalledWith(undefined); + }); + }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index d2af0d6dd49d..7e969b0b9366 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1536,7 +1536,7 @@ function _validateBeforeSendResult( /** * Process the matching `beforeSendXXX` callback. */ -// eslint-disable-next-line complexity + function processBeforeSend( client: Client, options: ClientOptions, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b59888c1bdd6..f4a41197ca33 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,6 +92,7 @@ export { } from './utils/spanUtils'; export { captureSpan } from './spans/captureSpan'; export { safeSetSpanJSONAttributes } from './spans/spanFirstUtils'; +export { SpanBuffer, type SpanBufferOptions } from './spans/spanBuffer'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; @@ -125,6 +126,7 @@ export { thirdPartyErrorFilterIntegration } from './integrations/third-party-err export { consoleIntegration } from './integrations/console'; export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; +export { serverSpanStreamingIntegration } from './integrations/serverSpanStreaming'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) diff --git a/packages/core/src/integrations/serverSpanStreaming.ts b/packages/core/src/integrations/serverSpanStreaming.ts new file mode 100644 index 000000000000..ca2b844e2374 --- /dev/null +++ b/packages/core/src/integrations/serverSpanStreaming.ts @@ -0,0 +1,54 @@ +import { DEBUG_BUILD } from '../debug-build'; +import { defineIntegration } from '../integration'; +import { captureSpan } from '../spans/captureSpan'; +import { SpanBuffer } from '../spans/spanBuffer'; +import type { IntegrationFn } from '../types-hoist/integration'; +import { isV2BeforeSendSpanCallback } from '../utils/beforeSendSpan'; +import { debug } from '../utils/debug-logger'; + +export interface ServerSpanStreamingOptions { + /** Max spans per envelope batch (default: 1000) */ + maxSpanLimit?: number; + /** Flush interval in ms (default: 5000) */ + flushInterval?: number; +} + +const INTEGRATION_NAME = 'ServerSpanStreaming'; + +const _serverSpanStreamingIntegration = ((options?: ServerSpanStreamingOptions) => { + return { + name: INTEGRATION_NAME, + setup(client) { + const clientOptions = client.getOptions(); + const beforeSendSpan = clientOptions.beforeSendSpan; + + const initialMessage = 'serverSpanStreamingIntegration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + + if (clientOptions.traceLifecycle !== 'stream') { + client.getOptions().traceLifecycle = 'static'; + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); + return; + } + + if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) { + client.getOptions().traceLifecycle = 'static'; + DEBUG_BUILD && + debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamSpan\`! ${fallbackMsg}`); + return; + } + + const buffer = new SpanBuffer(client, options); + + client.on('enqueueSpan', spanJSON => { + buffer.addSpan(spanJSON); + }); + + client.on('afterSpanEnd', span => { + captureSpan(span, client); + }); + }, + }; +}) satisfies IntegrationFn; + +export const serverSpanStreamingIntegration = defineIntegration(_serverSpanStreamingIntegration); diff --git a/packages/core/src/spans/spanBuffer.ts b/packages/core/src/spans/spanBuffer.ts new file mode 100644 index 000000000000..4514d81cbac7 --- /dev/null +++ b/packages/core/src/spans/spanBuffer.ts @@ -0,0 +1,126 @@ +import type { Client } from '../client'; +import { DEBUG_BUILD } from '../debug-build'; +import { createSpanV2Envelope } from '../envelope'; +import { getDynamicSamplingContextFromSpan } from '../tracing/dynamicSamplingContext'; +import type { SpanV2JSON, SpanV2JSONWithSegmentRef } from '../types-hoist/span'; +import { debug } from '../utils/debug-logger'; + +export interface SpanBufferOptions { + /** Max spans per trace before auto-flush (default: 1000) */ + maxSpanLimit?: number; + /** Flush interval in ms (default: 5000) */ + flushInterval?: number; +} + +/** + * A buffer for span JSON objects that flushes them to Sentry in Span v2 envelopes. + * Handles interval-based flushing, size thresholds, and graceful shutdown. + */ +export class SpanBuffer { + private _spanTreeMap: Map>; + private _flushIntervalId: ReturnType | null; + private _client: Client; + private _maxSpanLimit: number; + private _flushInterval: number; + + public constructor(client: Client, options?: SpanBufferOptions) { + this._spanTreeMap = new Map(); + this._client = client; + + const { maxSpanLimit, flushInterval } = options ?? {}; + + this._maxSpanLimit = maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= 1000 ? maxSpanLimit : 1000; + this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000; + + this._flushIntervalId = setInterval(() => { + this.flush(); + }, this._flushInterval); + + this._client.on('flush', () => { + this.flush(); + }); + } + + /** + * Add a span to the buffer. + */ + public addSpan(spanJSON: SpanV2JSONWithSegmentRef): void { + const traceId = spanJSON.trace_id; + let traceBucket = this._spanTreeMap.get(traceId); + if (traceBucket) { + traceBucket.add(spanJSON); + } else { + traceBucket = new Set([spanJSON]); + this._spanTreeMap.set(traceId, traceBucket); + } + + if (traceBucket.size >= this._maxSpanLimit) { + this._flushTrace(traceId); + this._debounceFlushInterval(); + } + } + + /** + * Flush all buffered traces. + */ + public flush(): void { + if (!this._spanTreeMap.size) { + return; + } + + DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._spanTreeMap.size} traces`); + + this._spanTreeMap.forEach((_, traceId) => { + this._flushTrace(traceId); + }); + this._debounceFlushInterval(); + } + + private _flushTrace(traceId: string): void { + const traceBucket = this._spanTreeMap.get(traceId); + if (!traceBucket) { + return; + } + + if (!traceBucket.size) { + this._spanTreeMap.delete(traceId); + return; + } + + const firstSpanJSON = traceBucket.values().next().value; + + const segmentSpan = firstSpanJSON?._segmentSpan; + if (!segmentSpan) { + DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); + this._spanTreeMap.delete(traceId); + return; + } + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + const cleanedSpans: SpanV2JSON[] = Array.from(traceBucket).map(spanJSON => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _segmentSpan, ...cleanSpanJSON } = spanJSON; + return cleanSpanJSON; + }); + + const envelope = createSpanV2Envelope(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 span stream envelope:', reason); + }); + + this._spanTreeMap.delete(traceId); + } + + private _debounceFlushInterval(): void { + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._flushIntervalId = setInterval(() => { + this.flush(); + }, this._flushInterval); + } +} diff --git a/packages/core/test/lib/integrations/serverSpanStreaming.test.ts b/packages/core/test/lib/integrations/serverSpanStreaming.test.ts new file mode 100644 index 000000000000..ddb5ff81f811 --- /dev/null +++ b/packages/core/test/lib/integrations/serverSpanStreaming.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client } from '../../../src'; +import { SentrySpan, setCurrentClient } from '../../../src'; +import { serverSpanStreamingIntegration } from '../../../src/integrations/serverSpanStreaming'; +import { debug } from '../../../src/utils/debug-logger'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('serverSpanStreamingIntegration', () => { + let client: TestClient; + let sendEnvelopeSpy: ReturnType; + + const debugWarnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + + beforeEach(() => { + vi.useFakeTimers(); + sendEnvelopeSpy = vi.fn().mockResolvedValue({}); + + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + }), + ); + client.sendEnvelope = sendEnvelopeSpy; + setCurrentClient(client as Client); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('has the correct name', () => { + const integration = serverSpanStreamingIntegration(); + expect(integration.name).toBe('ServerSpanStreaming'); + }); + + it("doesn't set up if traceLifecycle is not stream", () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1.0, + traceLifecycle: 'static', + debug: true, + }), + ); + + client.sendEnvelope = sendEnvelopeSpy; + setCurrentClient(client as Client); + + const integration = serverSpanStreamingIntegration(); + integration.setup?.(client); + client.init(); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + client.emit('afterSpanEnd', segmentSpan); + + // Should not buffer anything because integration didn't set up + vi.advanceTimersByTime(5000); + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + expect(debugWarnSpy).toHaveBeenCalledWith( + 'serverSpanStreamingIntegration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', + ); + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it("doesn't set up if beforeSendSpan callback is not a valid v2 callback", () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + debug: true, + beforeSendSpan: span => { + return span; + }, + }), + ); + client.sendEnvelope = sendEnvelopeSpy; + setCurrentClient(client as Client); + + const integration = serverSpanStreamingIntegration(); + integration.setup?.(client); + client.init(); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + client.emit('afterSpanEnd', segmentSpan); + expect(debugWarnSpy).toHaveBeenCalledWith( + 'serverSpanStreamingIntegration requires a beforeSendSpan callback using `withStreamSpan`! Falling back to static trace lifecycle.', + ); + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('captures spans on afterSpanEnd hook', () => { + const integration = serverSpanStreamingIntegration(); + integration.setup?.(client); + client.init(); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // Simulate span end which would trigger afterSpanEnd + client.emit('afterSpanEnd', segmentSpan); + + // Integration should have called captureSpan which emits enqueueSpan + // Then the buffer should flush on interval + vi.advanceTimersByTime(5000); + + expect(sendEnvelopeSpy).toHaveBeenCalledOnce(); + }); + + it('respects maxSpanLimit option', () => { + const integration = serverSpanStreamingIntegration({ maxSpanLimit: 1 }); + integration.setup?.(client); + client.init(); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // Enqueue span directly (simulating what captureSpan does) + client.emit('enqueueSpan', { + 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, + }); + + // Should flush immediately since maxSpanLimit is 1 + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('respects flushInterval option', () => { + const integration = serverSpanStreamingIntegration({ flushInterval: 1000 }); + integration.setup?.(client); + client.init(); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + client.emit('enqueueSpan', { + 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(); + + vi.advanceTimersByTime(1000); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/lib/spans/spanBuffer.test.ts b/packages/core/test/lib/spans/spanBuffer.test.ts new file mode 100644 index 000000000000..1bf330d63ed0 --- /dev/null +++ b/packages/core/test/lib/spans/spanBuffer.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client } from '../../../src'; +import { SentrySpan, setCurrentClient } from '../../../src'; +import { SpanBuffer } from '../../../src/spans/spanBuffer'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('SpanBuffer', () => { + let client: TestClient; + let sendEnvelopeSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + sendEnvelopeSpy = vi.fn().mockResolvedValue({}); + + 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 flush()', () => { + 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.addSpan({ + 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.addSpan({ + 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.flush(); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + const calls = sendEnvelopeSpy.mock.calls; + expect(calls[0]?.[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace123'); + expect(calls[1]?.[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace456'); + }); + + it('flushes on interval', () => { + const buffer = new SpanBuffer(client, { flushInterval: 1000 }); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.addSpan({ + 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(); + + 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.addSpan({ + 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.addSpan({ + 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.addSpan({ + 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); + + // we added another span after flushing but neither limit nor time interval should have been reached + buffer.flush(); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes on client flush event', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.addSpan({ + 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.addSpan({ + 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.addSpan({ + 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(); + + // Should send 2 envelopes, one for each trace + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index e670a86f50d6..a0e7d85e2827 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -8,6 +8,7 @@ import { initAndBind, linkedErrorsIntegration, nodeStackLineParser, + serverSpanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { DenoClient } from './client'; @@ -21,7 +22,7 @@ import { makeFetchTransport } from './transports'; import type { DenoOptions } from './types'; /** Get the default integrations for the Deno SDK. */ -export function getDefaultIntegrations(_options: Options): Integration[] { +export function getDefaultIntegrations(options: Options): Integration[] { // We return a copy of the defaultIntegrations here to avoid mutating this return [ // Common @@ -37,6 +38,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { contextLinesIntegration(), normalizePathsIntegration(), globalHandlersIntegration(), + ...(options.traceLifecycle === 'stream' ? [serverSpanStreamingIntegration()] : []), ]; } diff --git a/packages/deno/test/sdk.test.ts b/packages/deno/test/sdk.test.ts index 7848f6d372eb..41253f38b982 100644 --- a/packages/deno/test/sdk.test.ts +++ b/packages/deno/test/sdk.test.ts @@ -1,6 +1,38 @@ +import { assertEquals } from 'https://deno.land/std@0.202.0/assert/assert_equals.ts'; import { assertNotEquals } from 'https://deno.land/std@0.202.0/assert/assert_not_equals.ts'; -import { init } from '../build/esm/index.js'; +import { getDefaultIntegrations, init } from '../build/esm/index.js'; Deno.test('init() should return client', () => { assertNotEquals(init({}), undefined); }); + +Deno.test('getDefaultIntegrations returns list of integrations with default options', () => { + const integrations = getDefaultIntegrations({}).map(integration => integration.name); + assertEquals(integrations, [ + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'Dedupe', + 'Breadcrumbs', + 'DenoContext', + 'ContextLines', + 'NormalizePaths', + 'GlobalHandlers', + ]); +}); + +Deno.test('getDefaultIntegrations returns serverSpanStreamingIntegration if traceLifecycle is stream', () => { + const integrations = getDefaultIntegrations({ traceLifecycle: 'stream' }).map(integration => integration.name); + assertEquals(integrations, [ + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'Dedupe', + 'Breadcrumbs', + 'DenoContext', + 'ContextLines', + 'NormalizePaths', + 'GlobalHandlers', + 'ServerSpanStreaming', + ]); +}); diff --git a/packages/opentelemetry/src/streamingSpanExporter.ts b/packages/opentelemetry/src/streamingSpanExporter.ts index 30c6b6f69452..9fffe36b6cc6 100644 --- a/packages/opentelemetry/src/streamingSpanExporter.ts +++ b/packages/opentelemetry/src/streamingSpanExporter.ts @@ -1,15 +1,12 @@ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import type { Client, Span, SpanV2JSON, SpanV2JSONWithSegmentRef } from '@sentry/core'; +import type { Client, Span } from '@sentry/core'; import { captureSpan, - createSpanV2Envelope, - debug, - getDynamicSamplingContextFromSpan, safeSetSpanJSONAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SpanBuffer, } from '@sentry/core'; -import { DEBUG_BUILD } from './debug-build'; import { getSpanData, type ISentrySpanExporter } from './spanExporter'; type StreamingSpanExporterOptions = { @@ -23,29 +20,17 @@ type StreamingSpanExporterOptions = { * OTel span instances to avoid mutating already-ended spans. */ export class StreamingSpanExporter implements ISentrySpanExporter { - private _flushInterval: number; - private _maxSpanLimit: number; - - private _spanTreeMap: Map>; - - private _flushIntervalId: NodeJS.Timeout | null; - + private _buffer: SpanBuffer; private _client: Client; public constructor(client: Client, options?: StreamingSpanExporterOptions) { - this._spanTreeMap = new Map(); this._client = client; + this._buffer = new SpanBuffer(client, { + maxSpanLimit: options?.maxSpanLimit, + flushInterval: options?.flushInterval, + }); - const safeMaxSpanLimit = - options?.maxSpanLimit && options.maxSpanLimit > 0 && options.maxSpanLimit <= 1000 ? options.maxSpanLimit : 1000; - const safeFlushInterval = options?.flushInterval && options?.flushInterval > 0 ? options.flushInterval : 5_000; - this._flushInterval = safeFlushInterval; - this._maxSpanLimit = safeMaxSpanLimit; - - this._flushIntervalId = setInterval(() => { - this.flush(); - }, this._flushInterval); - + // OTel-specific: add span attributes from ReadableSpan this._client.on('processSpan', (spanJSON, hint) => { const { readOnlySpan } = hint; // TODO: This can be simplified by using spanJSON to get the data instead of the readOnlySpan @@ -61,23 +46,7 @@ export class StreamingSpanExporter implements ISentrySpanExporter { }); this._client.on('enqueueSpan', spanJSON => { - const traceId = spanJSON.trace_id; - let traceBucket = this._spanTreeMap.get(traceId); - if (traceBucket) { - traceBucket.add(spanJSON); - } else { - traceBucket = new Set([spanJSON]); - this._spanTreeMap.set(traceId, traceBucket); - } - - if (traceBucket.size >= this._maxSpanLimit) { - this._flushTrace(traceId); - this._debounceFlushInterval(); - } - }); - - this._client.on('flush', () => { - this.flush(); + this._buffer.addSpan(spanJSON); }); } @@ -90,20 +59,9 @@ export class StreamingSpanExporter implements ISentrySpanExporter { /** * Try to flush any pending spans immediately. - * This is called internally by the exporter (via _debouncedFlush), - * but can also be triggered externally if we force-flush. */ public flush(): void { - if (!this._spanTreeMap.size) { - return; - } - - debug.log(`Flushing span tree map with ${this._spanTreeMap.size} traces`); - - this._spanTreeMap.forEach((_, traceId) => { - this._flushTrace(traceId); - }); - this._debounceFlushInterval(); + this._buffer.flush(); } /** @@ -111,68 +69,6 @@ export class StreamingSpanExporter implements ISentrySpanExporter { * This is called when the span processor is shut down. */ public clear(): void { - if (this._flushIntervalId) { - clearInterval(this._flushIntervalId); - this._flushIntervalId = null; - } - // TODO (span-streaming): record client outcome for leftover spans? - this._spanTreeMap.clear(); - } - - /** - * Flush a trace from the span tree map. - */ - private _flushTrace(traceId: string): void { - const traceBucket = this._spanTreeMap.get(traceId); - if (!traceBucket) { - return; - } - - if (!traceBucket.size) { - this._spanTreeMap.delete(traceId); - return; - } - - // we checked against empty bucket above, so we can safely get the first span JSON here - const firstSpanJSON = traceBucket.values().next().value; - - // Extract the segment span reference for DSC calculation - const segmentSpan = firstSpanJSON?._segmentSpan; - if (!segmentSpan) { - DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); - this._spanTreeMap.delete(traceId); - return; - } - - const dsc = getDynamicSamplingContextFromSpan(segmentSpan); - - // Clean up segment span references before sending - const cleanedSpans: SpanV2JSON[] = Array.from(traceBucket).map(spanJSON => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { _segmentSpan, ...cleanSpanJSON } = spanJSON; - return cleanSpanJSON; - }); - - const envelope = createSpanV2Envelope(cleanedSpans, dsc, this._client); - - 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 span stream envelope:', reason); - }); - - this._spanTreeMap.delete(traceId); - } - - /** - * Debounce (reset) the flush interval. - */ - private _debounceFlushInterval(): void { - if (this._flushIntervalId) { - clearInterval(this._flushIntervalId); - } - this._flushIntervalId = setInterval(() => { - this.flush(); - }, this._flushInterval); + // No-op for streaming exporter - spans are flushed immediately on interval } } diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index b09c506f15c2..d8147965c6f4 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -22,6 +22,7 @@ import { nodeStackLineParser, requestDataIntegration, SDK_VERSION, + serverSpanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { @@ -61,6 +62,7 @@ export function getDefaultIntegrations(options: Options): Integration[] { consoleIntegration(), // TODO(v11): integration can be included - but integration should not add IP address etc ...(options.sendDefaultPii ? [requestDataIntegration()] : []), + ...(options.traceLifecycle === 'stream' ? [serverSpanStreamingIntegration()] : []), ]; } diff --git a/packages/vercel-edge/test/sdk.test.ts b/packages/vercel-edge/test/sdk.test.ts new file mode 100644 index 000000000000..9e23304d5848 --- /dev/null +++ b/packages/vercel-edge/test/sdk.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { getDefaultIntegrations } from '../src'; + +describe('getDefaultIntegrations', () => { + it('returns list of integrations with default options', () => { + const integrations = getDefaultIntegrations({}).map(integration => integration.name); + expect(integrations).toEqual([ + 'Dedupe', + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'WinterCGFetch', + 'Console', + ]); + }); + + it('returns serverSpanStreamingIntegration if traceLifecycle is stream', () => { + const integrations = getDefaultIntegrations({ traceLifecycle: 'stream' }).map(integration => integration.name); + expect(integrations).toEqual([ + 'Dedupe', + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'WinterCGFetch', + 'Console', + 'ServerSpanStreaming', + ]); + }); + + it('returns requestDataIntegration if sendDefaultPii is true', () => { + const integrations = getDefaultIntegrations({ sendDefaultPii: true }).map(integration => integration.name); + expect(integrations).toEqual([ + 'Dedupe', + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'WinterCGFetch', + 'Console', + 'RequestData', + ]); + }); +}); From a3555ad79bec482fa1fd3610ce08da81bac239f5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 17 Dec 2025 12:02:32 +0100 Subject: [PATCH 55/58] raise size limits --- .size-limit.js | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 6b6dbd6f0b9b..8a7d7c150dd8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,14 +8,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '25 KB', + limit: '26KB', }, { name: '@sentry/browser - with treeshaking flags', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '24.1 KB', + limit: '25 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '42 KB', + limit: '43 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -89,21 +89,21 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '86 KB', + limit: '87 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '98 KB', + limit: '99 KB', }, { name: '@sentry/browser (incl. Feedback)', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '42 KB', + limit: '43 KB', }, { name: '@sentry/browser (incl. sendFeedback)', @@ -117,7 +117,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '35 KB', + limit: '36 KB', }, // React SDK (ESM) { @@ -126,7 +126,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '27 KB', + limit: '28 KB', }, { name: '@sentry/react (incl. Tracing)', @@ -134,7 +134,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '45 KB', + limit: '46 KB', }, // Vue SDK (ESM) { @@ -149,7 +149,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '44 KB', + limit: '45 KB', }, // Svelte SDK (ESM) { @@ -157,26 +157,26 @@ module.exports = [ path: 'packages/svelte/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '25 KB', + limit: '26 KB', }, // Browser CDN bundles { name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '27.5 KB', + limit: '28.5 KB', }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '43 KB', + limit: '44 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '80 KB', + limit: '81 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', @@ -190,21 +190,21 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '82 KB', + limit: '83 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '127 KB', + limit: '129 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '245 KB', + limit: '247 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', @@ -220,7 +220,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '47 KB', + limit: '48 KB', }, // SvelteKit SDK (ESM) { @@ -229,7 +229,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '43 KB', + limit: '44 KB', }, // Node-Core SDK (ESM) { @@ -238,7 +238,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '52 KB', + limit: '53 KB', }, // Node SDK (ESM) { @@ -247,14 +247,14 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '162 KB', + limit: '166 KB', }, { name: '@sentry/node - without tracing', path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '95 KB', + limit: '97 KB', ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -277,7 +277,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '111 KB', + limit: '112 KB', }, ]; From 780b812e94305bf7973e394de89ef2cc7e900cbf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 17 Dec 2025 12:11:03 +0100 Subject: [PATCH 56/58] save some bytes in httpContext integration when tree-shaking tracing --- .../browser/src/integrations/httpcontext.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 4c9884ddb848..cfadc494ce90 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -6,6 +6,9 @@ import { } from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; +// Treeshakable guard to remove all code related to tracing +declare const __SENTRY_TRACING__: boolean | undefined; + /** * Collects information about HTTP request headers and * attaches them to the event. @@ -20,19 +23,21 @@ export const httpContextIntegration = defineIntegration(() => { return; } - if (client.getOptions().traceLifecycle === 'stream') { - client.on('processSpan', spanJSON => { - if (spanJSON.is_segment) { - const { url, headers } = getHttpRequestData(); + if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { + if (client.getOptions().traceLifecycle === 'stream') { + client.on('processSpan', spanJSON => { + if (spanJSON.is_segment) { + const { url, headers } = getHttpRequestData(); - const attributeHeaders = httpHeadersToSpanAttributes(headers); + const attributeHeaders = httpHeadersToSpanAttributes(headers); - safeSetSpanJSONAttributes(spanJSON, { - [SEMANTIC_ATTRIBUTE_URL_FULL]: url, - ...attributeHeaders, - }); - } - }); + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_URL_FULL]: url, + ...attributeHeaders, + }); + } + }); + } } }, preprocessEvent(event) { From 2b9f6d513e7830dfa845de25a8ac5346071ae423 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 18 Dec 2025 10:30:36 +0100 Subject: [PATCH 57/58] fix test flakiness --- .../suites/span-first/web-vitals/web-vitals-ttfb/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts index 3a527a552990..9e5a1e48fc62 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts @@ -22,7 +22,7 @@ sentryTest('captures TTFB web vital', async ({ getLocalTestUrl, page }) => { const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;"); if (responseStart !== 0) { expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb']).toEqual({ - type: expect.stringMatching(/^double$/), + type: expect.stringMatching(/^(integer|double)$/), value: expect.any(Number), }); } From 411f3ea9cbb01e505a822f3bd919e795357050a7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 18 Dec 2025 11:23:25 +0100 Subject: [PATCH 58/58] fix regex --- .../suites/span-first/web-vitals/web-vitals-ttfb/test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts index 9e5a1e48fc62..aa393f745c1a 100644 --- a/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts +++ b/dev-packages/browser-integration-tests/suites/span-first/web-vitals/web-vitals-ttfb/test.ts @@ -22,13 +22,13 @@ sentryTest('captures TTFB web vital', async ({ getLocalTestUrl, page }) => { const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;"); if (responseStart !== 0) { expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb']).toEqual({ - type: expect.stringMatching(/^(integer|double)$/), + type: expect.stringMatching(/^(integer)|(double)$/), value: expect.any(Number), }); } expect(pageloadSpan!.attributes?.['ui.web_vital.ttfb.requestTime']).toEqual({ - type: expect.stringMatching(/^(integer|double)$/), + type: expect.stringMatching(/^(integer)|(double)$/), value: expect.any(Number), }); });