From 44f1152fb22419115448126c586456b94f121592 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 5 Feb 2026 19:00:57 +0100 Subject: [PATCH 1/9] feat(core): Add `captureSpan` pipeline and helpers --- packages/core/src/client.ts | 22 +- packages/core/src/semanticAttributes.ts | 34 +- packages/core/src/tracing/index.ts | 3 + .../core/src/tracing/spans/captureSpan.ts | 150 +++++++ .../lib/tracing/spans/captureSpan.test.ts | 418 ++++++++++++++++++ 5 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tracing/spans/captureSpan.ts create mode 100644 packages/core/test/lib/tracing/spans/captureSpan.test.ts diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index e9e3c03538f7..2cd29502a823 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -31,7 +31,7 @@ import type { RequestEventData } from './types-hoist/request'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; import type { Session, SessionAggregates } from './types-hoist/session'; import type { SeverityLevel } from './types-hoist/severity'; -import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span'; +import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; @@ -609,6 +609,16 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for when a span JSON is processed, to add some data to the span JSON. + */ + public on(hook: 'processSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void; + + /** + * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON. + */ + public on(hook: 'processSegmentSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void; + /** * Register a callback for when an idle span is allowed to auto-finish. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -881,6 +891,16 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; + /** + * Register a callback for when a span JSON is processed, to add some data to the span JSON. + */ + public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void; + + /** + * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON. + */ + public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void; + /** * Fire a hook indicating that an idle span is allowed to auto finish. */ diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 88b0f470dfa3..d1c46420e9e6 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -1,10 +1,16 @@ /** - * Use this attribute to represent the source of a span. - * Should be one of: custom, url, route, view, component, task, unknown - * + * Use this attribute to represent the source of a span name. + * Must be one of: custom, url, route, view, component, task + * TODO: Deprecate this attribute in favour of SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE */ export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source'; +/** + * Use this attribute to represent the source of a span name. + * Must be one of: custom, url, route, view, component, task + */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE = 'sentry.span.source'; + /** * Attributes that holds the sample rate that was locally applied to a span. * If this attribute is not defined, it means that the span inherited a sampling decision. @@ -40,6 +46,28 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; +/** The release version of the application */ +export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; +/** The environment name (e.g., "production", "staging", "development") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment'; +/** The segment name (e.g., "GET /users") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment.name'; +/** The id of the segment that this span belongs to. */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID = 'sentry.segment.id'; +/** The name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; +/** The version of the Sentry SDK */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; + +/** The user ID (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; +/** The user email (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; +/** The user IP address (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; +/** The user username (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; + /** * A custom span name set by users guaranteed to be taken over any automatically * inferred name. This attribute is removed before the span is sent. diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 9997cab3519b..3d3736876015 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -23,3 +23,6 @@ export { export { setMeasurement, timedEventsToMeasurements } from './measurement'; export { sampleSpan } from './sampling'; export { logSpanEnd, logSpanStart } from './logSpans'; + +// Span Streaming +export { captureSpan } from './spans/captureSpan'; diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts new file mode 100644 index 000000000000..a616ee1b21bf --- /dev/null +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -0,0 +1,150 @@ +import type { RawAttributes } from '../../attributes'; +import type { Client } from '../../client'; +import type { ScopeData } from '../../scope'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, +} from '../../semanticAttributes'; +import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; +import { isStreamedBeforeSendSpanCallback } from '../../utils/beforeSendSpan'; +import { debug } from '../../utils/debug-logger'; +import { getCombinedScopeData } from '../../utils/scopeData'; +import { + INTERNAL_getSegmentSpan, + showSpanDropWarning, + spanToStreamedSpanJSON, + streamedSpanJsonToSerializedSpan, +} from '../../utils/spanUtils'; +import { getCapturedScopesOnSpan } from '../utils'; + +type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { + _segmentSpan: Span; +}; + +/** + * Captures a span and returns a JSON representation to be enqueued for sending. + * + * IMPORTANT: This function converts the span to JSON immediately to avoid writing + * to an already-ended OTel span instance (which is blocked by the OTel Span class). + * + * @returns the final serialized span with a reference to its segment span. This reference + * is needed later on to compute the DSC for the span envelope. + */ +export function captureSpan(span: Span, client: Client): SerializedStreamedSpanWithSegmentSpan { + // Convert to JSON FIRST - we cannot write to an already-ended span + const spanJSON = spanToStreamedSpanJSON(span); + + const segmentSpan = INTERNAL_getSegmentSpan(span); + const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + + const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope); + + applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); + + if (span === segmentSpan) { + applyScopeToSegmentSpan(spanJSON, finalScopeData); + // Allow hook subscribers to add additional data to the segment span JSON + client.emit('processSegmentSpan', spanJSON); + } + + // Allow hook subscribers to add additional data to the span JSON + client.emit('processSpan', spanJSON); + + const { beforeSendSpan } = client.getOptions(); + const processedSpan = + beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan) + ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) + : spanJSON; + + // Backfill sentry.span.source from sentry.source for the PoC + // TODO(v11): Stop sending `sentry.source` attribute and only send `sentry.span.source` + if (processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]) { + safeSetSpanJSONAttributes(processedSpan, { + [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE], + }); + delete processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + } + + return { + ...streamedSpanJsonToSerializedSpan(processedSpan), + _segmentSpan: segmentSpan, + }; +} + +function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { + // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span + // This will follow in a separate PR +} + +function applyCommonSpanAttributes( + spanJSON: StreamedSpanJSON, + serializedSegmentSpan: StreamedSpanJSON, + client: Client, + scopeData: ScopeData, +): void { + const sdk = client.getSdkMetadata(); + const { release, environment, sendDefaultPii } = client.getOptions(); + + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, + ...(sendDefaultPii + ? { + [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + } + : {}), + ...scopeData.attributes, + }); +} + +/** + * Apply a user-provided beforeSendSpan callback to a span JSON. + */ +export function applyBeforeSendSpanCallback( + span: StreamedSpanJSON, + beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON, +): StreamedSpanJSON { + const modifedSpan = beforeSendSpan(span); + if (!modifedSpan) { + showSpanDropWarning(); + return span; + } + return modifedSpan; +} + +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.keys(newAttributes).forEach(key => { + if (!originalAttributes?.[key]) { + originalAttributes[key] = newAttributes[key]; + } + }); +} diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts new file mode 100644 index 000000000000..9933bc72c6b7 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + captureSpan, + getActiveSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, + startInactiveSpan, + startSpan, + withScope, + withStreamedSpan, +} from '../../../../src'; +import { _setSpanForScope } from '../../../../src/utils/spanOnScope'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('captureSpan', () => { + it('captures user attributes iff sendDefaultPii is true', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + const serializedSpan = captureSpan(span, client); + + expect(serializedSpan).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it.each([false, undefined])("doesn't capture user attributes if sendDefaultPii is %s", sendDefaultPii => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it('captures sdk name and version if available', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + _metadata: { + sdk: { + name: 'sentry.javascript.node', + version: '1.0.0', + integrations: ['UnhandledRejection', 'Dedupe'], + }, + }, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + value: 'sentry.javascript.node', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + value: '1.0.0', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + describe('client hooks', () => { + it('calls processSpan and processSegmentSpan hooks for a segment span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + + captureSpan(span, client); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); + expect(processSegmentSpanFn).toHaveBeenCalledWith( + expect.objectContaining({ span_id: span.spanContext().spanId }), + ); + }); + + it('only calls processSpan hook for a child span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const serializedChildSpan = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + return startSpan({ name: 'segment' }, () => { + const childSpan = startInactiveSpan({ name: 'child' }); + childSpan.end(); + return captureSpan(childSpan, client); + }); + }); + + expect(serializedChildSpan?.name).toBe('child'); + expect(serializedChildSpan?.is_segment).toBe(false); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: serializedChildSpan?.span_id })); + expect(processSegmentSpanFn).not.toHaveBeenCalled(); + }); + }); + + describe('beforeSendSpan', () => { + it('applies beforeSendSpan if it is a span streaming compatible callback', () => { + const beforeSendSpan = withStreamedSpan(vi.fn(span => span)); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); + }); + + it("doesn't apply beforeSendSpan if it is not a span streaming compatible callback", () => { + const beforeSendSpan = vi.fn(span => span); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + }); + + it('logs a warning if the beforeSendSpan callback returns null', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // @ts-expect-error - the types dissallow returning null but this is javascript, so we need to test it + const beforeSendSpan = withStreamedSpan(() => null); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); From b470df9926b91b36c836ab0a22f84d9b2789bf80 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 5 Feb 2026 19:05:33 +0100 Subject: [PATCH 2/9] lint --- packages/core/src/tracing/spans/captureSpan.ts | 1 - packages/core/test/lib/tracing/spans/captureSpan.test.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index a616ee1b21bf..5c0d194f69ae 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -17,7 +17,6 @@ import { } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { isStreamedBeforeSendSpanCallback } from '../../utils/beforeSendSpan'; -import { debug } from '../../utils/debug-logger'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 9933bc72c6b7..7fb906de7e5d 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { captureSpan, - getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -21,7 +20,6 @@ import { withScope, withStreamedSpan, } from '../../../../src'; -import { _setSpanForScope } from '../../../../src/utils/spanOnScope'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { From 75d687b97dba2600e9a4174ee30ee4af0fd89d2f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 5 Feb 2026 21:22:28 +0100 Subject: [PATCH 3/9] fix build error --- packages/core/src/utils/spanUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 6e4a95b61d7b..2168530a9c91 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -15,7 +15,7 @@ import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; import type { - SerializedSpan, + SerializedStreamedSpan, Span, SpanAttributes, SpanJSON, @@ -267,7 +267,7 @@ function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | unde * This is the final serialized span format that is sent to Sentry. * The returned serilaized spans must not be consumed by users or SDK integrations. */ -export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedSpan { +export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedStreamedSpan { return { ...spanJson, attributes: serializeAttributes(spanJson.attributes), From de1e0c2f98f41e8f2d0ba706b6b0e1a7c7173ad1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 09:46:07 +0100 Subject: [PATCH 4/9] review suggestions, more tests --- packages/core/src/tracing/sentrySpan.ts | 3 +- .../core/src/tracing/spans/captureSpan.ts | 15 +++--- packages/core/src/utils/spanUtils.ts | 3 +- .../lib/tracing/spans/captureSpan.test.ts | 48 +++++++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8bdae7129dba..7815cc34a597 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -264,7 +264,8 @@ export class SentrySpan implements Span { end_timestamp: this._endTime ?? this._startTime, is_segment: this._isStandaloneSpan || this === getRootSpan(this), status: getSimpleStatusMessage(this._status), - attributes: this._attributes, + // spread to avoid mutating the original object when later processing the span + attributes: { ...this._attributes }, links: getStreamedSpanLinks(this._links), }; } diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 5c0d194f69ae..9594d7d613de 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -54,11 +54,11 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW if (span === segmentSpan) { applyScopeToSegmentSpan(spanJSON, finalScopeData); - // Allow hook subscribers to add additional data to the segment span JSON + // Allow hook subscribers to mutate the segment span JSON client.emit('processSegmentSpan', spanJSON); } - // Allow hook subscribers to add additional data to the span JSON + // Allow hook subscribers to mutate the span JSON client.emit('processSpan', spanJSON); const { beforeSendSpan } = client.getOptions(); @@ -67,11 +67,12 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) : spanJSON; - // Backfill sentry.span.source from sentry.source for the PoC - // TODO(v11): Stop sending `sentry.source` attribute and only send `sentry.span.source` - if (processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]) { + // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. + // TODO(v11): Ensure we always only send `sentry.span.source` and remove this backfill. + const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + if (spanNameSource) { safeSetSpanJSONAttributes(processedSpan, { - [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE], + [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: spanNameSource, }); delete processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; } @@ -142,7 +143,7 @@ export function safeSetSpanJSONAttributes( const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); Object.keys(newAttributes).forEach(key => { - if (!originalAttributes?.[key]) { + if (!(key in originalAttributes)) { originalAttributes[key] = newAttributes[key]; } }); diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 2168530a9c91..6061c2fa0257 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -230,7 +230,8 @@ export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON { end_timestamp: spanTimeInputToSeconds(endTime), is_segment: span === INTERNAL_getSegmentSpan(span), status: getSimpleStatusMessage(status), - attributes, + // spread to avoid mutating the original object when later processing the span + attributes: { ...attributes }, links: getStreamedSpanLinks(links), }; } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 7fb906de7e5d..2e244b7c8256 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { StreamedSpanJSON } from '../../../../src'; import { captureSpan, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, @@ -20,6 +21,7 @@ import { withScope, withStreamedSpan, } from '../../../../src'; +import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { @@ -414,3 +416,49 @@ describe('captureSpan', () => { }); }); }); + +describe('safeSetSpanJSONAttributes', () => { + it('sets attributes that do not exist', () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { c: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("doesn't set attributes that already exist", () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2 }); + }); + + it.each([null, undefined])("doesn't overwrite attributes previously set to %s", val => { + const spanJSON = { attributes: { a: val, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: val, b: 2 }); + }); + + it("doesn't overwrite falsy attribute values (%s)", () => { + const spanJSON = { attributes: { a: false, b: '', c: 0 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1, b: 'test', c: 1 }); + + expect(spanJSON.attributes).toEqual({ a: false, b: '', c: 0 }); + }); + + it('handles an undefined attributes property', () => { + const spanJSON: Partial = {}; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: 1 }); + }); +}); From 7213d493f90229daa34ac7a7016519475a415573 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 14:22:55 +0100 Subject: [PATCH 5/9] stop deleting `sentry.source` and remove attribute spread in `spanToStreamedSpanJSON` --- packages/core/src/semanticAttributes.ts | 8 +------- packages/core/src/tracing/sentrySpan.ts | 3 +-- packages/core/src/tracing/spans/captureSpan.ts | 8 ++++---- packages/core/src/utils/spanUtils.ts | 3 +-- packages/core/test/lib/tracing/spans/captureSpan.test.ts | 7 +++---- 5 files changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index d1c46420e9e6..02b6a4ec08a6 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -1,16 +1,10 @@ /** * Use this attribute to represent the source of a span name. * Must be one of: custom, url, route, view, component, task - * TODO: Deprecate this attribute in favour of SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE + * TODO(v11): rename this to sentry.span.source' */ export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source'; -/** - * Use this attribute to represent the source of a span name. - * Must be one of: custom, url, route, view, component, task - */ -export const SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE = 'sentry.span.source'; - /** * Attributes that holds the sample rate that was locally applied to a span. * If this attribute is not defined, it means that the span inherited a sampling decision. diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 7815cc34a597..8bdae7129dba 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -264,8 +264,7 @@ export class SentrySpan implements Span { end_timestamp: this._endTime ?? this._startTime, is_segment: this._isStandaloneSpan || this === getRootSpan(this), status: getSimpleStatusMessage(this._status), - // spread to avoid mutating the original object when later processing the span - attributes: { ...this._attributes }, + attributes: this._attributes, links: getStreamedSpanLinks(this._links), }; } diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 9594d7d613de..722709cb5f9f 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -9,7 +9,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE, SEMANTIC_ATTRIBUTE_USER_EMAIL, SEMANTIC_ATTRIBUTE_USER_ID, SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, @@ -68,13 +67,14 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW : spanJSON; // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. - // TODO(v11): Ensure we always only send `sentry.span.source` and remove this backfill. + // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; if (spanNameSource) { safeSetSpanJSONAttributes(processedSpan, { - [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: spanNameSource, + // Purposefully not using a constant defined here like in other attributes: + // This will be the name for SEMANTIC_ATTRIBUTE_SENTRY_SOURCE in v11 + 'sentry.span.source': spanNameSource, }); - delete processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; } return { diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 6061c2fa0257..2168530a9c91 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -230,8 +230,7 @@ export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON { end_timestamp: spanTimeInputToSeconds(endTime), is_segment: span === INTERNAL_getSegmentSpan(span), status: getSimpleStatusMessage(status), - // spread to avoid mutating the original object when later processing the span - attributes: { ...attributes }, + attributes, links: getStreamedSpanLinks(links), }; } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 2e244b7c8256..5f10bc59aff7 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -11,7 +11,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, - SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE, SEMANTIC_ATTRIBUTE_USER_EMAIL, SEMANTIC_ATTRIBUTE_USER_ID, SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, @@ -84,7 +83,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: { + 'sentry.span.source': { value: 'custom', type: 'string', }, @@ -174,7 +173,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: { + 'sentry.span.source': { value: 'custom', type: 'string', }, @@ -254,7 +253,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE]: { + 'sentry.span.source': { value: 'custom', type: 'string', }, From 2de81820016f4b7842153d900107876fe836589f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 14:24:47 +0100 Subject: [PATCH 6/9] tests --- .../core/test/lib/tracing/spans/captureSpan.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 5f10bc59aff7..477287931310 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -11,6 +11,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SEMANTIC_ATTRIBUTE_USER_EMAIL, SEMANTIC_ATTRIBUTE_USER_ID, SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, @@ -87,6 +88,10 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string', @@ -177,6 +182,10 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string', @@ -257,6 +266,10 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string', From 5f8a730fc7ee899228f0b8af654ff305e146b840 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 14:29:12 +0100 Subject: [PATCH 7/9] fix safeSetAttributes bug --- packages/core/src/tracing/spans/captureSpan.ts | 4 ++-- packages/core/test/lib/tracing/spans/captureSpan.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 722709cb5f9f..c74d86ce05bb 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -142,8 +142,8 @@ export function safeSetSpanJSONAttributes( ): void { const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); - Object.keys(newAttributes).forEach(key => { - if (!(key in originalAttributes)) { + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { originalAttributes[key] = newAttributes[key]; } }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 477287931310..d429d50714a2 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -473,4 +473,13 @@ describe('safeSetSpanJSONAttributes', () => { expect(spanJSON.attributes).toEqual({ a: 1 }); }); + + it("doesn't apply undefined or null values to attributes", () => { + const spanJSON = { attributes: {} }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: undefined, b: null }); + + expect(spanJSON.attributes).toEqual({}); + }); }); From a92761bc02d91e825e8bd437171c520b1b746a47 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 14:32:26 +0100 Subject: [PATCH 8/9] . --- packages/core/src/tracing/spans/captureSpan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index c74d86ce05bb..b332f3339dba 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -144,7 +144,7 @@ export function safeSetSpanJSONAttributes( Object.entries(newAttributes).forEach(([key, value]) => { if (value != null && !(key in originalAttributes)) { - originalAttributes[key] = newAttributes[key]; + originalAttributes[key] = value; } }); } From 4a63e5160f71ad34ab18c9c6cf42cfa497dca965 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 14:32:50 +0100 Subject: [PATCH 9/9] size limit --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 8d7ad5e8e3cd..310ae6e5109d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -196,7 +196,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '44 KB', + limit: '45 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)',