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)', 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..02b6a4ec08a6 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -1,7 +1,7 @@ /** - * Use this attribute to represent the source of a span. - * Should be one of: custom, url, route, view, component, task, unknown - * + * Use this attribute to represent the source of a span name. + * Must be one of: custom, url, route, view, component, task + * TODO(v11): rename this to sentry.span.source' */ export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source'; @@ -40,6 +40,28 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; +/** The release version of the application */ +export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; +/** The environment name (e.g., "production", "staging", "development") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment'; +/** The segment name (e.g., "GET /users") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment.name'; +/** The id of the segment that this span belongs to. */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID = 'sentry.segment.id'; +/** The name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; +/** The version of the Sentry SDK */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; + +/** The user ID (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; +/** The user email (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; +/** The user IP address (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; +/** The user username (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; + /** * A custom span name set by users guaranteed to be taken over any automatically * inferred name. This attribute is removed before the span is sent. diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 9997cab3519b..3d3736876015 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -23,3 +23,6 @@ export { export { setMeasurement, timedEventsToMeasurements } from './measurement'; export { sampleSpan } from './sampling'; export { logSpanEnd, logSpanStart } from './logSpans'; + +// Span Streaming +export { captureSpan } from './spans/captureSpan'; diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts new file mode 100644 index 000000000000..b332f3339dba --- /dev/null +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -0,0 +1,150 @@ +import type { RawAttributes } from '../../attributes'; +import type { Client } from '../../client'; +import type { ScopeData } from '../../scope'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, +} from '../../semanticAttributes'; +import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; +import { isStreamedBeforeSendSpanCallback } from '../../utils/beforeSendSpan'; +import { getCombinedScopeData } from '../../utils/scopeData'; +import { + INTERNAL_getSegmentSpan, + showSpanDropWarning, + spanToStreamedSpanJSON, + streamedSpanJsonToSerializedSpan, +} from '../../utils/spanUtils'; +import { getCapturedScopesOnSpan } from '../utils'; + +type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { + _segmentSpan: Span; +}; + +/** + * Captures a span and returns a JSON representation to be enqueued for sending. + * + * IMPORTANT: This function converts the span to JSON immediately to avoid writing + * to an already-ended OTel span instance (which is blocked by the OTel Span class). + * + * @returns the final serialized span with a reference to its segment span. This reference + * is needed later on to compute the DSC for the span envelope. + */ +export function captureSpan(span: Span, client: Client): SerializedStreamedSpanWithSegmentSpan { + // Convert to JSON FIRST - we cannot write to an already-ended span + const spanJSON = spanToStreamedSpanJSON(span); + + const segmentSpan = INTERNAL_getSegmentSpan(span); + const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + + const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope); + + applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); + + if (span === segmentSpan) { + applyScopeToSegmentSpan(spanJSON, finalScopeData); + // Allow hook subscribers to mutate the segment span JSON + client.emit('processSegmentSpan', spanJSON); + } + + // Allow hook subscribers to mutate the span JSON + client.emit('processSpan', spanJSON); + + const { beforeSendSpan } = client.getOptions(); + const processedSpan = + beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan) + ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) + : spanJSON; + + // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. + // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source + const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + if (spanNameSource) { + safeSetSpanJSONAttributes(processedSpan, { + // Purposefully not using a constant defined here like in other attributes: + // This will be the name for SEMANTIC_ATTRIBUTE_SENTRY_SOURCE in v11 + 'sentry.span.source': spanNameSource, + }); + } + + return { + ...streamedSpanJsonToSerializedSpan(processedSpan), + _segmentSpan: segmentSpan, + }; +} + +function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { + // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span + // This will follow in a separate PR +} + +function applyCommonSpanAttributes( + spanJSON: StreamedSpanJSON, + serializedSegmentSpan: StreamedSpanJSON, + client: Client, + scopeData: ScopeData, +): void { + const sdk = client.getSdkMetadata(); + const { release, environment, sendDefaultPii } = client.getOptions(); + + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, + ...(sendDefaultPii + ? { + [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + } + : {}), + ...scopeData.attributes, + }); +} + +/** + * Apply a user-provided beforeSendSpan callback to a span JSON. + */ +export function applyBeforeSendSpanCallback( + span: StreamedSpanJSON, + beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON, +): StreamedSpanJSON { + const modifedSpan = beforeSendSpan(span); + if (!modifedSpan) { + showSpanDropWarning(); + return span; + } + return modifedSpan; +} + +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { + originalAttributes[key] = value; + } + }); +} diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 6e4a95b61d7b..2168530a9c91 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -15,7 +15,7 @@ import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; import type { - SerializedSpan, + SerializedStreamedSpan, Span, SpanAttributes, SpanJSON, @@ -267,7 +267,7 @@ function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | unde * This is the final serialized span format that is sent to Sentry. * The returned serilaized spans must not be consumed by users or SDK integrations. */ -export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedSpan { +export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedStreamedSpan { return { ...spanJson, attributes: serializeAttributes(spanJson.attributes), diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts new file mode 100644 index 000000000000..d429d50714a2 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { StreamedSpanJSON } from '../../../../src'; +import { + captureSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, + startInactiveSpan, + startSpan, + withScope, + withStreamedSpan, +} from '../../../../src'; +import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('captureSpan', () => { + it('captures user attributes iff sendDefaultPii is true', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + const serializedSpan = captureSpan(span, client); + + expect(serializedSpan).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it.each([false, undefined])("doesn't capture user attributes if sendDefaultPii is %s", sendDefaultPii => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + it('captures sdk name and version if available', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + _metadata: { + sdk: { + name: 'sentry.javascript.node', + version: '1.0.0', + integrations: ['UnhandledRejection', 'Dedupe'], + }, + }, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { + value: 'sentry.javascript.node', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { + value: '1.0.0', + type: 'string', + }, + }, + _segmentSpan: span, + }); + }); + + describe('client hooks', () => { + it('calls processSpan and processSegmentSpan hooks for a segment span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + + captureSpan(span, client); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); + expect(processSegmentSpanFn).toHaveBeenCalledWith( + expect.objectContaining({ span_id: span.spanContext().spanId }), + ); + }); + + it('only calls processSpan hook for a child span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii: true, + }), + ); + + const processSpanFn = vi.fn(); + const processSegmentSpanFn = vi.fn(); + client.on('processSpan', processSpanFn); + client.on('processSegmentSpan', processSegmentSpanFn); + + const serializedChildSpan = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); + + return startSpan({ name: 'segment' }, () => { + const childSpan = startInactiveSpan({ name: 'child' }); + childSpan.end(); + return captureSpan(childSpan, client); + }); + }); + + expect(serializedChildSpan?.name).toBe('child'); + expect(serializedChildSpan?.is_segment).toBe(false); + + expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: serializedChildSpan?.span_id })); + expect(processSegmentSpanFn).not.toHaveBeenCalled(); + }); + }); + + describe('beforeSendSpan', () => { + it('applies beforeSendSpan if it is a span streaming compatible callback', () => { + const beforeSendSpan = withStreamedSpan(vi.fn(span => span)); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); + }); + + it("doesn't apply beforeSendSpan if it is not a span streaming compatible callback", () => { + const beforeSendSpan = vi.fn(span => span); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + }); + + it('logs a warning if the beforeSendSpan callback returns null', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // @ts-expect-error - the types dissallow returning null but this is javascript, so we need to test it + const beforeSendSpan = withStreamedSpan(() => null); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + beforeSendSpan, + }), + ); + + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + + captureSpan(span, client); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); + +describe('safeSetSpanJSONAttributes', () => { + it('sets attributes that do not exist', () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { c: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("doesn't set attributes that already exist", () => { + const spanJSON = { attributes: { a: 1, b: 2 } }; + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 3 }); + + expect(spanJSON.attributes).toEqual({ a: 1, b: 2 }); + }); + + it.each([null, undefined])("doesn't overwrite attributes previously set to %s", val => { + const spanJSON = { attributes: { a: val, b: 2 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: val, b: 2 }); + }); + + it("doesn't overwrite falsy attribute values (%s)", () => { + const spanJSON = { attributes: { a: false, b: '', c: 0 } }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1, b: 'test', c: 1 }); + + expect(spanJSON.attributes).toEqual({ a: false, b: '', c: 0 }); + }); + + it('handles an undefined attributes property', () => { + const spanJSON: Partial = {}; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: 1 }); + + expect(spanJSON.attributes).toEqual({ a: 1 }); + }); + + it("doesn't apply undefined or null values to attributes", () => { + const spanJSON = { attributes: {} }; + + // @ts-expect-error - only passing a partial object for this test + safeSetSpanJSONAttributes(spanJSON, { a: undefined, b: null }); + + expect(spanJSON.attributes).toEqual({}); + }); +});