diff --git a/.size-limit.js b/.size-limit.js index 4f86a9f8a2ea..a677b6d6eb6b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -15,7 +15,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '24.1 KB', + limit: '25 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '86 KB', + limit: '87 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -103,7 +103,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '31 KB', + limit: '32 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', @@ -190,7 +190,7 @@ module.exports = [ name: 'CDN Bundle (incl. Logs, Metrics)', path: createCDNPath('bundle.logs.metrics.min.js'), gzip: true, - limit: '29 KB', + limit: '30 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', @@ -214,7 +214,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '81 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', @@ -241,7 +241,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '128 KB', + limit: '129 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -255,7 +255,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '131 KB', + limit: '133 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', @@ -269,7 +269,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '245 KB', + limit: '247 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', @@ -308,7 +308,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '43 KB', + limit: '44 KB', }, // Node-Core SDK (ESM) { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 70a6595d07d9..071f38f72ca9 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -41,6 +41,7 @@ export { } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; +export { spanStreamingIntegration } from './integrations/spanstreaming'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts new file mode 100644 index 000000000000..e8e3f8bcd542 --- /dev/null +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -0,0 +1,55 @@ +import type { IntegrationFn } from '@sentry/core'; +import { + captureSpan, + debug, + defineIntegration, + hasSpanStreamingEnabled, + isStreamedBeforeSendSpanCallback, + SpanBuffer, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +export const spanStreamingIntegration = defineIntegration(() => { + return { + name: 'SpanStreaming', + + beforeSetup(client) { + // If users only set spanStreamingIntegration, without traceLifecycle, we set it to "stream" for them. + // This avoids the classic double-opt-in problem we'd otherwise have in the browser SDK. + const clientOptions = client.getOptions(); + if (!clientOptions.traceLifecycle) { + DEBUG_BUILD && debug.warn('[SpanStreaming] set `traceLifecycle` to "stream"'); + clientOptions.traceLifecycle = 'stream'; + } + }, + + setup(client) { + const initialMessage = 'SpanStreaming integration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + + if (!hasSpanStreamingEnabled(client)) { + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); + return; + } + + const beforeSendSpan = client.getOptions().beforeSendSpan; + // If users misconfigure their SDK by opting into span streaming but + // using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle. + if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + client.getOptions().traceLifecycle = 'static'; + DEBUG_BUILD && + debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + return; + } + + const buffer = new SpanBuffer(client); + + client.on('afterSpanEnd', span => buffer.add(captureSpan(span, client))); + + // In addition to capturing the span, we also flush the trace when the segment + // span ends to ensure things are sent timely. We never know when the browser + // is closed, users navigate away, etc. + client.on('afterSegmentSpanEnd', segmentSpan => buffer.flush(segmentSpan.spanContext().traceId)); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts new file mode 100644 index 000000000000..6993e494f9ce --- /dev/null +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -0,0 +1,167 @@ +import * as SentryCore from '@sentry/core'; +import { debug } from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; +import { BrowserClient, spanStreamingIntegration } from '../../src'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; + +// Mock SpanBuffer as a class that can be instantiated +const mockSpanBufferInstance = vi.hoisted(() => ({ + flush: vi.fn(), + add: vi.fn(), + drain: vi.fn(), +})); + +const MockSpanBuffer = vi.hoisted(() => { + return vi.fn(() => mockSpanBufferInstance); +}); + +vi.mock('@sentry/core', async () => { + const original = await vi.importActual('@sentry/core'); + return { + ...original, + SpanBuffer: MockSpanBuffer, + }; +}); + +describe('spanStreamingIntegration', () => { + it('has the correct hooks', () => { + const integration = spanStreamingIntegration(); + expect(integration.name).toBe('SpanStreaming'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(integration.beforeSetup).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(integration.setup).toBeDefined(); + }); + + it('sets traceLifecycle to "stream" if not set', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('logs a warning if traceLifecycle is not set to "stream"', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'static', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + beforeSendSpan: (span: Span) => span, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('does nothing if traceLifecycle set to "stream"', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('enqueues a span into the buffer when the span ends', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test' }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({ + _segmentSpan: span, + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'test', + start_timestamp: expect.any(Number), + status: 'ok', + attributes: { + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: span.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'test', + }, + }, + }); + }); + + it('flushes the trace when the segment span ends', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test' }); + client.emit('afterSegmentSpanEnd', span); + + expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 588c8a62e97e..52e60daa79ec 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -11,6 +11,7 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; +import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb'; import type { CheckIn, MonitorConfig } from './types-hoist/checkin'; @@ -31,7 +32,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 { createClientReportEnvelope } from './utils/clientreport'; @@ -502,6 +503,10 @@ export abstract class Client { public addIntegration(integration: Integration): void { const isAlreadyInstalled = this._integrations[integration.name]; + if (!isAlreadyInstalled && integration.beforeSetup) { + integration.beforeSetup(this); + } + // This hook takes care of only installing if not already installed setupIntegration(this, integration, this._integrations); // Here we need to check manually to make sure to not run this multiple times @@ -612,6 +617,28 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for after a span is ended and the `spanEnd` hook has run. + * NOTE: The span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void; + + /** + * Register a callback for after a segment span is ended and the `segmentSpanEnd` hook has run. + * NOTE: The segment span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSegmentSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void; + + /** + * Register a callback for when a span JSON is processed, to add some data to the span JSON. + */ + 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. @@ -884,6 +911,26 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; + /** + * Fire a hook event after a span ends and the `spanEnd` hook has run. + */ + public emit(hook: 'afterSpanEnd', immutableSpan: Readonly): void; + + /** + * Fire a hook event after a segment span ends and the `spanEnd` hook has run. + */ + public emit(hook: 'afterSegmentSpanEnd', immutableSegmentSpan: Readonly): void; + + /** + * Fire a hook event when a span JSON is processed, to add some data to the span JSON. + */ + public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void; + + /** + * Fire a hook event for when a segment span JSON is processed, to add some data to the segment span JSON. + */ + public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void; + /** * Fire a hook indicating that an idle span is allowed to auto finish. */ @@ -1502,7 +1549,9 @@ function processBeforeSend( event: Event, hint: EventHint, ): PromiseLike | Event | null { - const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options; + const { beforeSend, beforeSendTransaction, ignoreSpans } = options; + const beforeSendSpan = !isStreamedBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; + let processedEvent = event; if (isErrorEvent(processedEvent) && beforeSend) { diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 875056890e0e..dd91d077f45c 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -1,6 +1,7 @@ import type { Client } from './client'; import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext'; import type { SentrySpan } from './tracing/sentrySpan'; +import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import type { LegacyCSPReport } from './types-hoist/csp'; import type { DsnComponents } from './types-hoist/dsn'; import type { @@ -152,7 +153,7 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? const convertToSpanJSON = beforeSendSpan ? (span: SentrySpan) => { const spanJson = spanToJSON(span); - const processedSpan = beforeSendSpan(spanJson); + const processedSpan = !isStreamedBeforeSendSpanCallback(beforeSendSpan) ? beforeSendSpan(spanJson) : spanJson; if (!processedSpan) { showSpanDropWarning(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d76917bf0cdd..ba78608d0a31 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,8 @@ export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; +export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; +export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; @@ -79,11 +81,13 @@ export { convertSpanLinksForEnvelope, spanToTraceHeader, spanToJSON, + spanToStreamedSpanJSON, spanIsSampled, spanToTraceContext, getSpanDescendants, getStatusMessage, getRootSpan, + INTERNAL_getSegmentSpan, getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, @@ -173,6 +177,10 @@ export type { GoogleGenAIOptions, GoogleGenAIIstrumentedMethod, } from './tracing/google-genai/types'; + +export { SpanBuffer } from './tracing/spans/spanBuffer'; +export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; + export type { FeatureFlag } from './utils/featureFlags'; export { @@ -387,6 +395,7 @@ export type { ProfileChunkEnvelope, ProfileChunkItem, SpanEnvelope, + StreamedSpanEnvelope, SpanItem, LogEnvelope, MetricEnvelope, @@ -454,6 +463,7 @@ export type { SpanJSON, SpanContextData, TraceFlag, + StreamedSpanJSON, } 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/integration.ts b/packages/core/src/integration.ts index 892228476824..b8e7240cf748 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -76,6 +76,12 @@ export function getIntegrationsToSetup( export function setupIntegrations(client: Client, integrations: Integration[]): IntegrationIndex { const integrationIndex: IntegrationIndex = {}; + integrations.forEach((integration: Integration | undefined) => { + if (integration?.beforeSetup) { + integration.beforeSetup(client); + } + }); + integrations.forEach((integration: Integration | undefined) => { // guard against empty provided integrations if (integration) { diff --git a/packages/core/src/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/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 47d5657a7d87..7cf79e53d07b 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -119,7 +119,8 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly { + * // span is of type StreamedSpanJSON + * return span; + * }), + * }); + * + * @param callback - The callback function that receives and returns a {@link StreamedSpanJSON}. + * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'stream'`. + */ +export function withStreamedSpan( + callback: (span: StreamedSpanJSON) => StreamedSpanJSON, +): BeforeSendStramedSpanCallback { + addNonEnumerableProperty(callback, '_streamed', true); + return callback; +} + +/** + * Typesafe check to identify if a `beforeSendSpan` callback expects the streamed span JSON format. + * + * @param callback - The `beforeSendSpan` callback to check. + * @returns `true` if the callback was wrapped with {@link withStreamedSpan}. + */ +export function isStreamedBeforeSendSpanCallback( + callback: ClientOptions['beforeSendSpan'], +): callback is BeforeSendStramedSpanCallback { + return !!callback && '_streamed' in callback && !!callback._streamed; +} diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts new file mode 100644 index 000000000000..979c7b460af1 --- /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 { getCombinedScopeData } from '../../utils/scopeData'; +import { + INTERNAL_getSegmentSpan, + showSpanDropWarning, + spanToStreamedSpanJSON, + streamedSpanJsonToSerializedSpan, +} from '../../utils/spanUtils'; +import { getCapturedScopesOnSpan } from '../utils'; +import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; + +export 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 (spanJSON.is_segment) { + 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/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts new file mode 100644 index 000000000000..8429b22d7e1c --- /dev/null +++ b/packages/core/src/tracing/spans/envelope.ts @@ -0,0 +1,36 @@ +import type { Client } from '../../client'; +import type { DynamicSamplingContext, SpanContainerItem, StreamedSpanEnvelope } from '../../types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; +import { dsnToString } from '../../utils/dsn'; +import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope'; + +/** + * Creates a span v2 span streaming envelope + */ +export function createStreamedSpanEnvelope( + serializedSpans: Array, + dsc: Partial, + client: Client, +): StreamedSpanEnvelope { + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + const sdk = getSdkMetadataForEnvelopeHeader(client.getOptions()._metadata); + + const headers: StreamedSpanEnvelope[0] = { + sent_at: new Date().toISOString(), + ...(dscHasRequiredProps(dsc) && { trace: dsc }), + ...(sdk && { sdk }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; + + const spanContainer: SpanContainerItem = [ + { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: serializedSpans }, + ]; + + return createEnvelope(headers, [spanContainer]); +} + +function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { + return !!dsc.trace_id && !!dsc.public_key; +} diff --git a/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts new file mode 100644 index 000000000000..7d5fa2861c21 --- /dev/null +++ b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts @@ -0,0 +1,8 @@ +import type { Client } from '../../client'; + +/** + * Determines if span streaming is enabled for the given client + */ +export function hasSpanStreamingEnabled(client: Client): boolean { + return client.getOptions().traceLifecycle === 'stream'; +} diff --git a/packages/core/src/tracing/spans/spanBuffer.ts b/packages/core/src/tracing/spans/spanBuffer.ts new file mode 100644 index 000000000000..761ba076e4d2 --- /dev/null +++ b/packages/core/src/tracing/spans/spanBuffer.ts @@ -0,0 +1,173 @@ +import type { Client } from '../../client'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; +import { debug } from '../../utils/debug-logger'; +import { safeUnref } from '../../utils/timer'; +import { getDynamicSamplingContextFromSpan } from '../dynamicSamplingContext'; +import type { SerializedStreamedSpanWithSegmentSpan } from './captureSpan'; +import { createStreamedSpanEnvelope } from './envelope'; + +/** + * We must not send more than 1000 spans in one envelope. + * Otherwise the envelope is dropped by Relay. + */ +const MAX_SPANS_PER_ENVELOPE = 1000; + +export interface SpanBufferOptions { + /** + * Max spans per trace before auto-flush + * Must not exceed 1000. + * + * @default 1_000 + */ + maxSpanLimit?: number; + + /** + * Flush interval in ms + * Must be greater than 0. + * + * @default 5_000 + */ + flushInterval?: number; +} + +/** + * A buffer for serialized streamed span JSON objects that flushes them to Sentry in Span v2 envelopes. + * Handles interval-based flushing, size thresholds, and graceful shutdown. + * Also handles computation of the Dynamic Sampling Context (DSC) for the trace, if it wasn't yet + * frozen onto the segment span. + * + * For this, we need the reference to the segment span instance, from + * which we compute the DSC. Doing this in the buffer ensures that we compute the DSC as late as possible, + * allowing span name and data updates up to this point. Worth noting here that the segment span is likely + * still active and modifyable when child spans are added to the buffer. + */ +export class SpanBuffer { + /* Bucket spans by their trace id */ + private _traceMap: Map>; + + private _flushIntervalId: ReturnType | null; + private _client: Client; + private _maxSpanLimit: number; + private _flushInterval: number; + + public constructor(client: Client, options?: SpanBufferOptions) { + this._traceMap = new Map(); + this._client = client; + + const { maxSpanLimit, flushInterval } = options ?? {}; + + this._maxSpanLimit = + maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE + ? maxSpanLimit + : MAX_SPANS_PER_ENVELOPE; + this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000; + + this._flushIntervalId = null; + this._debounceFlushInterval(); + + this._client.on('flush', () => { + this.drain(); + }); + + this._client.on('close', () => { + // No need to drain the buffer here as `Client.close()` internally already calls `Client.flush()` + // which already invokes the `flush` hook and thus drains the buffer. + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._traceMap.clear(); + }); + } + + /** + * Add a span to the buffer. + */ + public add(spanJSON: SerializedStreamedSpanWithSegmentSpan): void { + const traceId = spanJSON.trace_id; + let traceBucket = this._traceMap.get(traceId); + if (traceBucket) { + traceBucket.add(spanJSON); + } else { + traceBucket = new Set([spanJSON]); + this._traceMap.set(traceId, traceBucket); + } + + if (traceBucket.size >= this._maxSpanLimit) { + this.flush(traceId); + this._debounceFlushInterval(); + } + } + + /** + * Drain and flush all buffered traces. + */ + public drain(): void { + if (!this._traceMap.size) { + return; + } + + DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._traceMap.size} traces`); + + this._traceMap.forEach((_, traceId) => { + this.flush(traceId); + }); + this._debounceFlushInterval(); + } + + /** + * Flush spans of a specific trace. + * In contrast to {@link SpanBuffer.flush}, this method does not flush all traces, but only the one with the given traceId. + */ + public flush(traceId: string): void { + const traceBucket = this._traceMap.get(traceId); + if (!traceBucket) { + return; + } + + if (!traceBucket.size) { + // we should never get here, given we always add a span when we create a new bucket + // and delete the bucket once we flush out the trace + this._traceMap.delete(traceId); + return; + } + + const spans = Array.from(traceBucket); + + const segmentSpan = spans[0]?._segmentSpan; + if (!segmentSpan) { + DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); + this._traceMap.delete(traceId); + return; + } + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + const cleanedSpans: SerializedStreamedSpan[] = spans.map(spanJSON => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _segmentSpan, ...cleanSpanJSON } = spanJSON; + return cleanSpanJSON; + }); + + const envelope = createStreamedSpanEnvelope(cleanedSpans, dsc, this._client); + + DEBUG_BUILD && debug.log(`Sending span envelope for trace ${traceId} with ${cleanedSpans.length} spans`); + + this._client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason); + }); + + this._traceMap.delete(traceId); + } + + private _debounceFlushInterval(): void { + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._flushIntervalId = safeUnref( + setInterval(() => { + this.drain(); + }, this._flushInterval), + ); + } +} diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 28a5bccd4147..59b00bb018c1 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -492,6 +492,7 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp // If it has an endTimestamp, it's already ended if (spanArguments.endTimestamp) { client.emit('spanEnd', childSpan); + client.emit('afterSpanEnd', childSpan); } } diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 272f8cde9f62..d8b8a1822b04 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -11,7 +11,7 @@ import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; -import type { SpanJSON } from './span'; +import type { SerializedStreamedSpanContainer, SpanJSON } from './span'; // Based on: https://develop.sentry.dev/sdk/envelopes/ @@ -91,6 +91,21 @@ type CheckInItemHeaders = { type: 'check_in' }; type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type SpanItemHeaders = { type: 'span' }; +type SpanContainerItemHeaders = { + /** + * Same as v1 span item type but this envelope is distinguished by {@link SpanContainerItemHeaders.content_type}. + */ + type: 'span'; + /** + * The number of span items in the container. This must be the same as the number of span items in the payload. + */ + item_count: number; + /** + * The content type of the span items. This must be `application/vnd.sentry.items.span.v2+json`. + * (the presence of this field also distinguishes the span item from the v1 span item) + */ + content_type: 'application/vnd.sentry.items.span.v2+json'; +}; type LogContainerItemHeaders = { type: 'log'; /** @@ -123,6 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; +export type SpanContainerItem = BaseEnvelopeItem; export type LogContainerItem = BaseEnvelopeItem; export type MetricContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; @@ -133,6 +149,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type StreamedSpanEnvelopeHeaders = 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 StreamedSpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -157,6 +175,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope + | StreamedSpanEnvelope | RawSecurityEnvelope | LogEnvelope | MetricEnvelope; diff --git a/packages/core/src/types-hoist/integration.ts b/packages/core/src/types-hoist/integration.ts index 120cb1acc884..fc80cf3f524a 100644 --- a/packages/core/src/types-hoist/integration.ts +++ b/packages/core/src/types-hoist/integration.ts @@ -14,6 +14,15 @@ export interface Integration { */ setupOnce?(): void; + /** + * Called before the `setup` hook of any integration is called. + * This is useful if an integration needs to e.g. modify client options prior to other integrations + * reading client options. + * + * @param client + */ + beforeSetup?(client: Client): void; + /** * Set up an integration for the given client. * Receives the client as argument. diff --git a/packages/core/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 92292f8e6e3d..63310a66c3d2 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -6,7 +6,7 @@ import type { Log } from './log'; import type { Metric } from './metric'; import type { TracesSamplerSamplingContext } from './samplingcontext'; import type { SdkMetadata } from './sdkmetadata'; -import type { SpanJSON } from './span'; +import type { SpanJSON, StreamedSpanJSON } from './span'; import type { StackLineParser, StackParser } from './stacktrace'; import type { TracePropagationTargets } from './tracing'; import type { BaseTransportOptions, Transport } from './transport'; @@ -500,6 +500,14 @@ export interface ClientOptions SpanJSON; + beforeSendSpan?: ((span: SpanJSON) => SpanJSON) | BeforeSendStramedSpanCallback; /** * An event-processing callback for transaction events, guaranteed to be invoked after all other event @@ -615,6 +626,19 @@ export interface ClientOptions Breadcrumb | null; } +/** + * A callback for processing streamed spans before they are sent. + * + * @see {@link StreamedSpanJSON} for the streamed span format used with `traceLifecycle: 'stream'` + */ +export type BeforeSendStramedSpanCallback = ((span: StreamedSpanJSON) => StreamedSpanJSON) & { + /** + * When true, indicates this callback is designed to handle the {@link StreamedSpanJSON} format + * used with `traceLifecycle: 'stream'`. Set this by wrapping your callback with `withStreamedSpan`. + */ + _streamed?: true; +}; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index d82463768b7f..a918cc57859c 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,3 +1,4 @@ +import type { Attributes, RawAttributes } from '../attributes'; import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; @@ -34,6 +35,43 @@ export type SpanAttributes = Partial<{ /** This type is aligned with the OpenTelemetry TimeInput type. */ export type SpanTimeInput = HrTime | number | Date; +/** + * Intermediate JSON reporesentation of a v2 span, which users and our SDK integrations will interact with. + * This is NOT the final serialized JSON span, but an intermediate step still holding raw attributes. + * The final, serialized span is a {@link SerializedStreamedSpan}. + * Main reason: Make it easier and safer for users to work with attributes. + */ +export interface StreamedSpanJSON { + trace_id: string; + parent_span_id?: string; + span_id: string; + name: string; + start_timestamp: number; + end_timestamp: number; + status: 'ok' | 'error'; + is_segment: boolean; + attributes?: RawAttributes>; + links?: SpanLinkJSON>>[]; +} + +/** + * Serialized span item. + * This is the final, serialized span format that is sent to Sentry. + * The intermediate representation is {@link StreamedSpanJSON}. + * Main difference: Attributes are converted to {@link Attributes}, thus including the `type` annotation. + */ +export type SerializedStreamedSpan = Omit & { + attributes?: Attributes; + links?: SpanLinkJSON[]; +}; + +/** + * Envelope span item container. + */ +export type SerializedStreamedSpanContainer = { + items: Array; +}; + /** A JSON representation of a span. */ export interface SpanJSON { data: SpanAttributes; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index d7c261ecd73c..2168530a9c91 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -1,4 +1,6 @@ import { getAsyncContextStrategy } from '../asyncContext'; +import type { RawAttributes } from '../attributes'; +import { serializeAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import { @@ -12,7 +14,15 @@ import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; -import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput } from '../types-hoist/span'; +import type { + SerializedStreamedSpan, + Span, + SpanAttributes, + SpanJSON, + SpanOrigin, + SpanTimeInput, + StreamedSpanJSON, +} from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; @@ -105,6 +115,27 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] } } +/** + * Converts the span links array to a flattened version with serialized attributes for V2 spans. + * + * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent. + */ +export function getStreamedSpanLinks( + links?: SpanLink[], +): SpanLinkJSON>>[] | undefined { + if (links?.length) { + return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ + span_id: spanId, + trace_id: traceId, + sampled: traceFlags === TRACE_FLAG_SAMPLED, + attributes, + ...restContext, + })); + } else { + return undefined; + } +} + /** * Convert a span time input into a timestamp in seconds. */ @@ -150,23 +181,12 @@ export function spanToJSON(span: Span): SpanJSON { if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { const { attributes, startTime, name, endTime, status, links } = span; - // In preparation for the next major of OpenTelemetry, we want to support - // looking up the parent span id according to the new API - // In OTel v1, the parent span id is accessed as `parentSpanId` - // In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` - const parentSpanId = - 'parentSpanId' in span - ? span.parentSpanId - : 'parentSpanContext' in span - ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId - : undefined; - return { span_id, trace_id, data: attributes, description: name, - parent_span_id: parentSpanId, + parent_span_id: getOtelParentSpanId(span), start_timestamp: spanTimeInputToSeconds(startTime), // This is [0,0] by default in OTEL, in which case we want to interpret this as no end time timestamp: spanTimeInputToSeconds(endTime) || undefined, @@ -187,6 +207,77 @@ export function spanToJSON(span: Span): SpanJSON { }; } +/** + * Convert a span to the intermediate {@link StreamedSpanJSON} representation. + */ +export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON { + if (spanIsSentrySpan(span)) { + return span.getStreamedSpanJSON(); + } + + const { spanId: span_id, traceId: trace_id } = span.spanContext(); + + // Handle a span from @opentelemetry/sdk-base-trace's `Span` class + if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { + const { attributes, startTime, name, endTime, status, links } = span; + + return { + name, + span_id, + trace_id, + parent_span_id: getOtelParentSpanId(span), + start_timestamp: spanTimeInputToSeconds(startTime), + end_timestamp: spanTimeInputToSeconds(endTime), + is_segment: span === INTERNAL_getSegmentSpan(span), + status: getSimpleStatusMessage(status), + attributes, + links: getStreamedSpanLinks(links), + }; + } + + // Finally, as a fallback, at least we have `spanContext()`.... + // This should not actually happen in reality, but we need to handle it for type safety. + return { + span_id, + trace_id, + start_timestamp: 0, + name: '', + end_timestamp: 0, + status: 'ok', + is_segment: span === INTERNAL_getSegmentSpan(span), + }; +} + +/** + * In preparation for the next major of OpenTelemetry, we want to support + * looking up the parent span id according to the new API + * In OTel v1, the parent span id is accessed as `parentSpanId` + * In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + */ +function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | undefined { + return 'parentSpanId' in span + ? span.parentSpanId + : 'parentSpanContext' in span + ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId + : undefined; +} + +/** + * Converts a {@link StreamedSpanJSON} to a {@link SerializedSpan}. + * This is the final serialized span format that is sent to Sentry. + * The returned serilaized spans must not be consumed by users or SDK integrations. + */ +export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedStreamedSpan { + return { + ...spanJson, + attributes: serializeAttributes(spanJson.attributes), + links: spanJson.links?.map(link => ({ + ...link, + attributes: serializeAttributes(link.attributes), + })), + }; +} + function spanIsOpenTelemetrySdkTraceBaseSpan(span: Span): span is OpenTelemetrySdkTraceBaseSpan { const castSpan = span as Partial; return !!castSpan.attributes && !!castSpan.startTime && !!castSpan.name && !!castSpan.endTime && !!castSpan.status; @@ -237,6 +328,13 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef return status.message || 'internal_error'; } +/** + * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default). + */ +export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { + return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error'; +} + const CHILD_SPANS_FIELD = '_sentryChildSpans'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; @@ -298,7 +396,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] { /** * Returns the root span of a given span. */ -export function getRootSpan(span: SpanWithPotentialChildren): Span { +export const getRootSpan = INTERNAL_getSegmentSpan; + +/** + * Returns the segment span of a given span. + */ +export function INTERNAL_getSegmentSpan(span: SpanWithPotentialChildren): Span { return span[ROOT_SPAN_FIELD] || span; } diff --git a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts new file mode 100644 index 000000000000..79fd838a1b27 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { withStreamedSpan } from '../../../../src'; +import { isStreamedBeforeSendSpanCallback } from '../../../../src/tracing/spans/beforeSendSpan'; + +describe('beforeSendSpan for span streaming', () => { + describe('withStreamedSpan', () => { + it('should be able to modify the span', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStreamedSpan(beforeSendSpan); + expect(wrapped._streamed).toBe(true); + }); + }); + + describe('isStreamedBeforeSendSpanCallback', () => { + it('returns true if the callback is wrapped with withStreamedSpan', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStreamedSpan(beforeSendSpan); + expect(isStreamedBeforeSendSpanCallback(wrapped)).toBe(true); + }); + + it('returns false if the callback is not wrapped with withStreamedSpan', () => { + const beforeSendSpan = vi.fn(); + expect(isStreamedBeforeSendSpanCallback(beforeSendSpan)).toBe(false); + }); + }); +}); 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({}); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts new file mode 100644 index 000000000000..197b7ed40365 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/envelope.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import { createStreamedSpanEnvelope } from '../../../../src/tracing/spans/envelope'; +import type { DynamicSamplingContext } from '../../../../src/types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function createMockSerializedSpan(overrides: Partial = {}): SerializedStreamedSpan { + return { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: 1713859200, + end_timestamp: 1713859201, + status: 'ok', + is_segment: false, + ...overrides, + }; +} + +describe('createStreamedSpanEnvelope', () => { + describe('envelope headers', () => { + it('creates an envelope with sent_at header', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sent_at', expect.any(String)); + }); + + it('includes trace header when DSC has required props (trace_id and public_key)', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + sample_rate: '1.0', + release: 'v1.0.0', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('trace', dsc); + }); + + it("does't include trace header when DSC is missing trace_id", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + public_key: 'public-key-abc', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it("does't include trace header when DSC is missing public_key", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + trace_id: 'trace-123', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it('includes SDK info when available in client options', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + _metadata: { + sdk: { name: 'sentry.javascript.browser', version: '8.0.0' }, + }, + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sdk', { name: 'sentry.javascript.browser', version: '8.0.0' }); + }); + + it("does't include SDK info when not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('sdk'); + }); + + it('includes DSN when tunnel and DSN are configured', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('dsn', 'https://abc123@example.sentry.io/456'); + }); + + it("does't include DSN when tunnel is not configured", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it("does't include DSN when DSN is not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it('includes all headers when all options are provided', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + _metadata: { + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + }, + }), + ); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + environment: 'production', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toEqual({ + sent_at: expect.any(String), + trace: dsc, + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + dsn: 'https://abc123@example.sentry.io/456', + }); + }); + }); + + describe('envelope item', () => { + it('creates a span container item with correct structure', () => { + const mockSpan = createMockSerializedSpan({ name: 'span-1' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 1, + type: 'span', + }, + { + items: [mockSpan], + }, + ], + ]); + }); + + it('sets correct item_count for multiple spans', () => { + const mockSpan1 = createMockSerializedSpan({ span_id: 'span-1' }); + const mockSpan2 = createMockSerializedSpan({ span_id: 'span-2' }); + const mockSpan3 = createMockSerializedSpan({ span_id: 'span-3' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan1, mockSpan2, mockSpan3], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 3, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: [mockSpan1, mockSpan2, mockSpan3] }, + ], + ]); + }); + + it('handles empty spans array', () => { + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([], dsc, mockClient); + + expect(result).toEqual([ + { + sent_at: expect.any(String), + }, + [ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 0, + type: 'span', + }, + { + items: [], + }, + ], + ], + ]); + }); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts new file mode 100644 index 000000000000..1b654cd400e6 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client, StreamedSpanEnvelope } from '../../../../src'; +import { SentrySpan, setCurrentClient, SpanBuffer } from '../../../../src'; +import type { SerializedStreamedSpanWithSegmentSpan } from '../../../../src/tracing/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('SpanBuffer', () => { + let client: TestClient; + let sendEnvelopeSpy: ReturnType; + + let sentEnvelopes: Array = []; + + beforeEach(() => { + vi.useFakeTimers(); + sentEnvelopes = []; + sendEnvelopeSpy = vi.fn().mockImplementation(e => { + sentEnvelopes.push(e); + return Promise.resolve(); + }); + + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1.0, + }), + ); + client.sendEnvelope = sendEnvelopeSpy; + client.init(); + setCurrentClient(client as Client); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('flushes all traces on drain()', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace123' }); + const segmentSpan2 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace456' }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace456', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + expect(sentEnvelopes).toHaveLength(2); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace123'); + expect(sentEnvelopes[1]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace456'); + }); + + it('drains on interval', () => { + const buffer = new SpanBuffer(client, { flushInterval: 1000 }); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true }); + const span1 = { + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }; + + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + const span2 = { + trace_id: 'trace123', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }; + + buffer.add(span1 as SerializedStreamedSpanWithSegmentSpan); + buffer.add(span2 as SerializedStreamedSpanWithSegmentSpan); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // since the buffer is now empty, it should not send anything anymore + vi.advanceTimersByTime(1000); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('flushes when maxSpanLimit is reached', () => { + const buffer = new SpanBuffer(client, { maxSpanLimit: 2 }); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span3', + name: 'test span 3', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + // we added another span after flushing but neither limit nor time interval should have been reached + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // draining will flush out the remaining span + buffer.drain(); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes on client flush event', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + client.emit('flush'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('groups spans by traceId', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + // Should send 2 envelopes, one for each trace + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes a specific trace on flush(traceId)', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace1'); + }); + + it('handles flushing a non-existing trace', () => { + const buffer = new SpanBuffer(client); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index bca9a406dd50..e4a0b31990d7 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -4,6 +4,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, SentrySpan, setCurrentClient, SPAN_STATUS_ERROR, @@ -16,7 +17,7 @@ import { TRACEPARENT_REGEXP, } from '../../../src'; import type { SpanLink } from '../../../src/types-hoist/link'; -import type { Span, SpanAttributes, SpanTimeInput } from '../../../src/types-hoist/span'; +import type { Span, SpanAttributes, SpanTimeInput, StreamedSpanJSON } from '../../../src/types-hoist/span'; import type { SpanStatus } from '../../../src/types-hoist/spanStatus'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; import { @@ -24,7 +25,9 @@ import { spanIsSampled, spanTimeInputToSeconds, spanToJSON, + spanToStreamedSpanJSON, spanToTraceContext, + streamedSpanJsonToSerializedSpan, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, updateSpanName, @@ -41,6 +44,7 @@ function createMockedOtelSpan({ status = { code: SPAN_STATUS_UNSET }, endTime = Date.now(), parentSpanId, + links = undefined, }: { spanId: string; traceId: string; @@ -51,6 +55,7 @@ function createMockedOtelSpan({ status?: SpanStatus; endTime?: SpanTimeInput; parentSpanId?: string; + links?: SpanLink[]; }): Span { return { spanContext: () => { @@ -66,6 +71,7 @@ function createMockedOtelSpan({ status, endTime, parentSpanId, + links, } as OpenTelemetrySdkTraceBaseSpan; } @@ -409,6 +415,233 @@ describe('spanToJSON', () => { }); }); + describe('spanToStreamedSpanJSON', () => { + describe('SentrySpan', () => { + it('converts a minimal span', () => { + const span = new SentrySpan(); + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + name: '', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + }, + }); + }); + + it('converts a full span', () => { + const span = new SentrySpan({ + op: 'test op', + name: 'test name', + parentSpanId: '1234', + spanId: '5678', + traceId: 'abcd', + startTimestamp: 123, + endTimestamp: 456, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + attr1: 'value1', + attr2: 2, + attr3: true, + }, + links: [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + ], + }); + span.setStatus({ code: SPAN_STATUS_OK }); + span.setAttribute('attr4', [1, 2, 3]); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: 'value1', + attr2: 2, + attr3: true, + attr4: [1, 2, 3], + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }); + }); + }); + describe('OpenTelemetry Span', () => { + it('converts a simple span', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + name: 'test span', + startTime: 123, + endTime: [0, 0], + attributes: {}, + status: { code: SPAN_STATUS_UNSET }, + }); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: 'SPAN-1', + trace_id: 'TRACE-1', + parent_span_id: undefined, + start_timestamp: 123, + end_timestamp: 0, + name: 'test span', + is_segment: true, + status: 'ok', + attributes: {}, + }); + }); + + it('converts a full span', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + parentSpanId: 'PARENT-1', + name: 'test span', + startTime: 123, + endTime: 456, + attributes: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + status: { code: SPAN_STATUS_ERROR, message: 'unknown_error' }, + }); + + expect(spanToStreamedSpanJSON(span)).toEqual({ + span_id: 'SPAN-1', + trace_id: 'TRACE-1', + parent_span_id: 'PARENT-1', + start_timestamp: 123, + end_timestamp: 456, + name: 'test span', + is_segment: true, + status: 'error', + attributes: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }); + }); + }); + }); + + describe('streamedSpanJsonToSerializedSpan', () => { + it('converts a streamed span JSON with links to a serialized span', () => { + const spanJson: StreamedSpanJSON = { + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: 'value1', + attr2: 2, + attr3: true, + attr4: [1, 2, 3], + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ], + }; + + expect(streamedSpanJsonToSerializedSpan(spanJson)).toEqual({ + name: 'test name', + parent_span_id: '1234', + span_id: '5678', + trace_id: 'abcd', + start_timestamp: 123, + end_timestamp: 456, + status: 'ok', + is_segment: true, + attributes: { + attr1: { type: 'string', value: 'value1' }, + attr2: { type: 'integer', value: 2 }, + attr3: { type: 'boolean', value: true }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test op' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto' }, + // notice the absence of `attr4`! + // for now, we don't yet serialize array attributes. This test will fail + // once we allow serializing them. + }, + links: [ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { type: 'string', value: 'previous_trace' }, + }, + }, + ], + }); + }); + }); + it('returns minimal object for unknown span implementation', () => { const span = { // This is the minimal interface we require from a span