From ee9da954f0f9a7d0935a33cda1c7a7d63c8db5a8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Sep 2025 18:37:35 +0200 Subject: [PATCH 01/42] 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 c33d0107df5f..03cae37e0dbb 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/42] 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 387ba0aba4a2..9155819c2eb3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,7 @@ export { addChildSpanToSpan, spanTimeInputToSeconds, updateSpanName, + spanToV2JSON, } from './utils/spanUtils'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; @@ -321,6 +322,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 { @@ -439,6 +441,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 03cae37e0dbb..d383f1061d68 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'; @@ -390,6 +390,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 @@ -505,6 +515,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 5ffbbd2b72019a5cf732b21015665c09bfb5a048 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 1 Oct 2025 17:55:04 +0200 Subject: [PATCH 03/42] 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 9155819c2eb3..d020f1a9bce3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -374,6 +374,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 3b7c13f4e3aa15e69d2e8aa0c9843876a4d1000d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 2 Oct 2025 10:27:29 +0200 Subject: [PATCH 04/42] 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 d020f1a9bce3..09b4b90c29cc 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 20a47c0da82262382f7ab6b7773c71b2401224bf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 2 Oct 2025 16:46:09 +0200 Subject: [PATCH 05/42] 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 09b4b90c29cc..9209a2a2a34b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -81,11 +81,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'; @@ -323,6 +325,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 244025e57ca12256c790668755968ef0bffe1dbd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 3 Oct 2025 12:20:40 +0200 Subject: [PATCH 06/42] 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 6e6ee0f68303..8567c9ba4660 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 5a4b9d7af8c873652eaccc5ca1c3b4097530c954 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 3 Oct 2025 12:22:47 +0200 Subject: [PATCH 07/42] 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 9ef8ccf46e971f23c49e0929fdf3d2c99637c724 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 3 Oct 2025 18:05:40 +0200 Subject: [PATCH 08/42] 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 9209a2a2a34b..f3017b0f14b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,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 d383f1061d68..02ae7f0a1f92 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -398,7 +398,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 e7eea4d2d25f0c930a7d0a3959b433e203359639 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 14 Oct 2025 18:01:14 +0200 Subject: [PATCH 09/42] 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 02ae7f0a1f92..e7dcbb460da5 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/42] changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c903d7df829..878eaeef9803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -542,6 +542,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 1b28d9f003b78b847c506113322f0336ae22aa59 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 14 Oct 2025 18:34:05 +0200 Subject: [PATCH 11/42] size-limit bumps --- .size-limit.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 8567c9ba4660..b8ae1095b0f8 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)', @@ -96,7 +96,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '97 KB', + limit: '98 KB', }, { name: '@sentry/browser (incl. Feedback)', @@ -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 5346b4c21493cca2258a08a5e84947cc6240d37d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 14 Oct 2025 19:05:09 +0200 Subject: [PATCH 12/42] 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 bce731a9d7010cc746ea3e159eb62e3ef2f728da Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 15 Oct 2025 17:11:46 +0200 Subject: [PATCH 13/42] bump preview version --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878eaeef9803..d4ced7019654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -542,7 +542,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 69862a12e1c28c6e3bb86d5d47ef656e057bb7dd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 15 Oct 2025 17:21:42 +0200 Subject: [PATCH 14/42] 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 f3017b0f14b0..8224f4718226 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -325,7 +325,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 20f82459b4e6270dee86b5023fdeaa44ec0c2105 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 15 Oct 2025 18:21:41 +0200 Subject: [PATCH 15/42] 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 bc5372274ad6..ccd0971e3cf9 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -233,6 +233,9 @@ export function init(options: NodeOptions): NodeClient | undefined { } }); + // 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 07324c013f851771bd4ac302c74d52e0ef7b3329 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Oct 2025 14:06:01 +0200 Subject: [PATCH 16/42] changelog --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ced7019654..7b62663674fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -502,6 +502,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 @@ -542,10 +546,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 0882966f82e13fb7e5bf95e711f27a4e8eaac549 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Oct 2025 16:06:07 +0200 Subject: [PATCH 17/42] 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 522ce4a6920d5fb2c73a39493c9cb86b804404ea Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Oct 2025 16:06:55 +0200 Subject: [PATCH 18/42] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b62663674fd..576e41db1dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -502,6 +502,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 136845fa0f45d1052e64445d032acdb2c791807e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 31 Oct 2025 10:23:42 +0100 Subject: [PATCH 19/42] 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 4cc3bbc0aae7d84f4316d36c90f09b1107ecbfb7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 10 Nov 2025 17:14:25 +0100 Subject: [PATCH 20/42] 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 8224f4718226..9678af100ae0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -81,7 +81,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 54b4499a2e46c27af64ddb9b4a667c08ce4392cd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 10 Nov 2025 17:15:57 +0100 Subject: [PATCH 21/42] 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 b74b2760264445ce5ecd7cf4ef3235ef58f639c0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Nov 2025 11:33:25 +0100 Subject: [PATCH 22/42] 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 a2c33e863a7cba9c09da10069d74404f39f6a44d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Nov 2025 14:32:28 +0100 Subject: [PATCH 23/42] restart ci From ea489e7977c45ed4fc18a8ce4bab7ba8a12f15d1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 15:34:00 +0100 Subject: [PATCH 24/42] 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 b8ae1095b0f8..d6f2c0375f43 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 e6becfea559655024d652343fcfe3aa7a31ccee6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 15:45:27 +0100 Subject: [PATCH 25/42] kk limits fixed but raise limits :( --- .size-limit.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index d6f2c0375f43..8cf99a246eb5 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -59,7 +59,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '80 KB', + limit: '82 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -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) { @@ -247,7 +247,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '160 KB', + limit: '161 KB', }, { name: '@sentry/node - without tracing', From 68189769114c31b9ca08a5a14fbb61a61cacd088 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 16:07:34 +0100 Subject: [PATCH 26/42] 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 8cf99a246eb5..451c93957167 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 fdee9c701c331e3a6c765a3b2bbbc71869fb3195 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Nov 2025 13:52:11 +0100 Subject: [PATCH 27/42] 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 01c0fab6eb03294fa41f9ceef836ead86ac905fc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Nov 2025 14:57:45 +0100 Subject: [PATCH 28/42] 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 451c93957167..e6c0f5f3b1ab 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 f42d562f40177d5b846eba34900bf26dbe738dc0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Nov 2025 14:39:49 +0100 Subject: [PATCH 29/42] 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 9678af100ae0..5c49e38bd75a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,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 e7dcbb460da5..80d57ae8cea8 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 6bd700ed63fe3c32b9c44c1043d1e183ce800a6a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Nov 2025 16:18:09 +0100 Subject: [PATCH 30/42] 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 dae8d5b5db7d9e60997ca0d3782b9edaca5946b4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 1 Dec 2025 15:48:18 +0100 Subject: [PATCH 31/42] 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 a32d8e5cca942fda969e35ffc3c4270d16819b38 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 1 Dec 2025 17:07:49 +0100 Subject: [PATCH 32/42] 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 9c1fe8c3d80b4830ed22f657f8449aa2639d64e4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 1 Dec 2025 17:14:49 +0100 Subject: [PATCH 33/42] 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 587e3e89f3cdc61dfcf791df3bcbf0e2505e216c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 2 Dec 2025 10:37:57 +0100 Subject: [PATCH 34/42] 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 776e302a6b8f6562afc88b6c427cb0f750ef6484 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 2 Dec 2025 16:10:05 +0100 Subject: [PATCH 35/42] 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 5c49e38bd75a..f4b19d3708c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,6 +90,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 d9cd2f325fc8de2170aaabeabea94de60c643cbf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 4 Dec 2025 10:19:23 +0100 Subject: [PATCH 36/42] 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 80d57ae8cea8..e7dcbb460da5 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 571a9614b7754be971c9e25f29079a5ebeebda5c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 4 Dec 2025 16:36:41 +0100 Subject: [PATCH 37/42] 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 7473fb21b0c9a77ceb10c222e43a57d5ae9aaa44 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 5 Dec 2025 16:42:20 +0100 Subject: [PATCH 38/42] 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 f4b19d3708c0..3726412f972d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,7 +90,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 7557d73c74a2..73f225652130 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 9d5941c41c8e..41dc72c4e2d2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -191,4 +191,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 45408a466cba1f009554f1055e1a14f23421a330 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 10 Dec 2025 15:50:03 +0100 Subject: [PATCH 39/42] 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 3726412f972d..be5a0f64170d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,7 +90,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'; @@ -450,6 +449,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 8602881d0bf38aef8aceda053dd85cf66fc6df34 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 10 Dec 2025 16:36:00 +0100 Subject: [PATCH 40/42] 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 07633a3a65e0db2c4cc93758718c0f849f6b4d2e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 10 Dec 2025 17:55:21 +0100 Subject: [PATCH 41/42] 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 282eed07b4d0828fdbe3adf2c37535f8404da6f3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 11 Dec 2025 13:41:28 +0100 Subject: [PATCH 42/42] 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 be5a0f64170d..d7404dc9a98b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,7 +90,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; });