diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/init.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/init.js new file mode 100644 index 000000000000..f568455a877c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/subject.js new file mode 100644 index 000000000000..e504cc75b843 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/subject.js @@ -0,0 +1,4 @@ +Sentry.startSpan({ name: 'outer' }, () => { + Sentry.startSpan({ name: 'inner' }, () => {}); + Sentry.startSpan({ name: 'standalone', experimental: { standalone: true } }, () => {}); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/test.ts new file mode 100644 index 000000000000..36aab28d2a77 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/test.ts @@ -0,0 +1,121 @@ +import { expect } from '@playwright/test'; +import type { Envelope, EventEnvelope, SpanEnvelope, TransactionEvent } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest( + 'sends a transaction and a span envelope if a standalone span is created as a child of an ongoing span tree', + async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + const envelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: ['transaction', 'span'] }, + properFullEnvelopeRequestParser, + ); + + const spanEnvelope = envelopes.find(envelope => envelope[1][0][0].type === 'span') as SpanEnvelope; + const transactionEnvelope = envelopes.find(envelope => envelope[1][0][0].type === 'transaction') as EventEnvelope; + + const spanEnvelopeHeader = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + const transactionEnvelopeHeader = transactionEnvelope[0]; + const transactionEnvelopeItem = transactionEnvelope[1][0][1] as TransactionEvent; + + const traceId = transactionEnvelopeHeader.trace!.trace_id!; + const parentSpanId = transactionEnvelopeItem.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f0-9]{32}/); + expect(parentSpanId).toMatch(/[a-f0-9]{16}/); + + // TODO: the span envelope also needs to contain the `trace` header (follow-up PR) + expect(spanEnvelopeHeader).toEqual({ + sent_at: expect.any(String), + }); + + expect(transactionEnvelopeHeader).toEqual({ + event_id: expect.any(String), + sdk: { + name: 'sentry.javascript.browser', + version: expect.any(String), + }, + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: traceId, + transaction: 'outer', + }, + }); + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.origin': 'manual', + }, + description: 'standalone', + segment_id: transactionEnvelopeItem.contexts?.trace?.span_id, + parent_span_id: parentSpanId, + origin: 'manual', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); + + expect(transactionEnvelopeItem).toEqual({ + contexts: { + trace: { + data: { + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + }, + origin: 'manual', + span_id: parentSpanId, + trace_id: traceId, + }, + }, + environment: 'production', + event_id: expect.any(String), + platform: 'javascript', + request: { + headers: expect.any(Object), + url: expect.any(String), + }, + sdk: expect.any(Object), + spans: [ + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner', + origin: 'manual', + parent_span_id: parentSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }, + ], + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'outer', + transaction_info: { + source: 'custom', + }, + type: 'transaction', + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/init.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/init.js new file mode 100644 index 000000000000..f568455a877c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/subject.js new file mode 100644 index 000000000000..4ce33ad3b8c4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/subject.js @@ -0,0 +1 @@ +Sentry.startSpan({ name: 'standalone_segment_span', experimental: { standalone: true } }, () => {}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/test.ts new file mode 100644 index 000000000000..aab881e34580 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/test.ts @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test'; +import type { SpanEnvelope } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest('sends a segment span envelope', async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + const spanEnvelope = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser); + + const headers = spanEnvelope[0]; + const item = spanEnvelope[1][0]; + + const itemHeader = item[0]; + const spanJson = item[1]; + + expect(headers).toEqual({ + sent_at: expect.any(String), + }); + + expect(itemHeader).toEqual({ + type: 'span', + }); + + expect(spanJson).toEqual({ + data: { + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + }, + description: 'standalone_segment_span', + origin: 'manual', + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + is_segment: true, + segment_id: spanJson.span_id, + }); +}); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index d1e27f194e12..b4fe7d2607ff 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -74,6 +74,13 @@ export const properEnvelopeRequestParser = (request: Request | null, return properEnvelopeParser(request)[0][envelopeIndex] as T; }; +export const properFullEnvelopeRequestParser = (request: Request | null): T => { + // https://develop.sentry.dev/sdk/envelopes/ + const envelope = request?.postData() || ''; + + return parseEnvelope(envelope) as T; +}; + export const envelopeHeaderRequestParser = (request: Request | null): EventEnvelopeHeaders => { // https://develop.sentry.dev/sdk/envelopes/ const envelope = request?.postData() || ''; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 31fd35bc4b68..a57305632c99 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -4,6 +4,7 @@ import type { SpanAttributeValue, SpanAttributes, SpanContextData, + SpanEnvelope, SpanJSON, SpanOrigin, SpanStatus, @@ -16,6 +17,7 @@ import { dropUndefinedKeys, logger, timestampInSeconds, uuid4 } from '@sentry/ut import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; +import { createSpanEnvelope } from '../envelope'; import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary'; import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, @@ -58,6 +60,9 @@ export class SentrySpan implements Span { /** The timed events added to this span. */ protected _events: TimedEvent[]; + /** if true, treat span as a standalone span (not part of a transaction) */ + private _isStandaloneSpan?: boolean; + /** * You should never call the constructor manually, always use `Sentry.startSpan()` * or other span methods. @@ -96,6 +101,8 @@ export class SentrySpan implements Span { if (this._endTime) { this._onSpanEnded(); } + + this._isStandaloneSpan = spanContext.isStandalone; } /** @inheritdoc */ @@ -188,6 +195,8 @@ export class SentrySpan implements Span { profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, exclusive_time: this._attributes[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME] as number | undefined, measurements: timedEventsToMeasurements(this._events), + is_segment: (this._isStandaloneSpan && getRootSpan(this) === this) || undefined, + segment_id: this._isStandaloneSpan ? getRootSpan(this).spanContext().spanId : undefined, }); } @@ -220,6 +229,18 @@ export class SentrySpan implements Span { return this; } + /** + * This method should generally not be used, + * but for now we need a way to publicly check if the `_isStandaloneSpan` flag is set. + * USE THIS WITH CAUTION! + * @internal + * @hidden + * @experimental + */ + public isStandaloneSpan(): boolean { + return !!this._isStandaloneSpan; + } + /** Emit `spanEnd` when the span is ended. */ private _onSpanEnded(): void { const client = getClient(); @@ -227,13 +248,25 @@ export class SentrySpan implements Span { client.emit('spanEnd', this); } - // If this is a root span, send it when it is endedf - if (this === getRootSpan(this)) { - const transactionEvent = this._convertSpanToTransaction(); - if (transactionEvent) { - const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); - scope.captureEvent(transactionEvent); - } + // A segment span is basically the root span of a local span tree. + // So for now, this is either what we previously refer to as the root span, + // or a standalone span. + const isSegmentSpan = this._isStandaloneSpan || this === getRootSpan(this); + + if (!isSegmentSpan) { + return; + } + + // if this is a standalone span, we send it immediately + if (this._isStandaloneSpan) { + sendSpanEnvelope(createSpanEnvelope([this])); + return; + } + + const transactionEvent = this._convertSpanToTransaction(); + if (transactionEvent) { + const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); + scope.captureEvent(transactionEvent); } } @@ -266,8 +299,8 @@ export class SentrySpan implements Span { return undefined; } - // The transaction span itself should be filtered out - const finishedSpans = getSpanDescendants(this).filter(span => span !== this); + // The transaction span itself as well as any potential standalone spans should be filtered out + const finishedSpans = getSpanDescendants(this).filter(span => span !== this && !isStandaloneSpan(span)); const spans = finishedSpans.map(span => spanToJSON(span)).filter(isFullFinishedSpan); @@ -318,3 +351,22 @@ function isSpanTimeInput(value: undefined | SpanAttributes | SpanTimeInput): val function isFullFinishedSpan(input: Partial): input is SpanJSON { return !!input.start_timestamp && !!input.timestamp && !!input.span_id && !!input.trace_id; } + +/** `SentrySpan`s can be sent as a standalone span rather than belonging to a transaction */ +function isStandaloneSpan(span: Span): boolean { + return span instanceof SentrySpan && span.isStandaloneSpan(); +} + +function sendSpanEnvelope(envelope: SpanEnvelope): void { + const client = getClient(); + if (!client) { + return; + } + + const transport = client.getTransport(); + if (transport) { + transport.send(envelope).then(null, reason => { + DEBUG_BUILD && logger.error('Error while sending span:', reason); + }); + } +} diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 065403c78aed..4d910f54e996 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -45,7 +45,7 @@ export function startSpan(context: StartSpanOptions, callback: (span: Span) = const shouldSkipSpan = context.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan ? new SentryNonRecordingSpan() - : createChildSpanOrTransaction({ + : createChildOrRootSpan({ parentSpan, spanContext, forceTransaction: context.forceTransaction, @@ -92,7 +92,7 @@ export function startSpanManual(context: StartSpanOptions, callback: (span: S const shouldSkipSpan = context.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan ? new SentryNonRecordingSpan() - : createChildSpanOrTransaction({ + : createChildOrRootSpan({ parentSpan, spanContext, forceTransaction: context.forceTransaction, @@ -144,7 +144,7 @@ export function startInactiveSpan(context: StartSpanOptions): Span { return new SentryNonRecordingSpan(); } - return createChildSpanOrTransaction({ + return createChildOrRootSpan({ parentSpan, spanContext, forceTransaction: context.forceTransaction, @@ -212,7 +212,7 @@ export function suppressTracing(callback: () => T): T { }); } -function createChildSpanOrTransaction({ +function createChildOrRootSpan({ parentSpan, spanContext, forceTransaction, @@ -291,14 +291,20 @@ function createChildSpanOrTransaction({ * Eventually the StartSpanOptions will be more aligned with OpenTelemetry. */ function normalizeContext(context: StartSpanOptions): SentrySpanArguments { + const exp = context.experimental || {}; + const initialCtx: SentrySpanArguments = { + isStandalone: exp.standalone, + ...context, + }; + if (context.startTime) { - const ctx: SentrySpanArguments & { startTime?: SpanTimeInput } = { ...context }; + const ctx: SentrySpanArguments & { startTime?: SpanTimeInput } = { ...initialCtx }; ctx.startTimestamp = spanTimeInputToSeconds(context.startTime); delete ctx.startTime; return ctx; } - return context; + return initialCtx; } function getAcs(): AsyncContextStrategy { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 110a90d01f6f..f2aa8460dba4 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -551,6 +551,41 @@ describe('startSpan', () => { expect(result).toBe('aha'); }); + + describe('[experimental] standalone spans', () => { + it('starts a standalone segment span if standalone is set', () => { + const span = startSpan( + { + name: 'test span', + experimental: { standalone: true }, + }, + span => { + return span; + }, + ); + + const spanJson = spanToJSON(span); + expect(spanJson.is_segment).toBe(true); + expect(spanJson.segment_id).toBe(spanJson.span_id); + expect(spanJson.segment_id).toMatch(/^[a-f0-9]{16}$/); + }); + + it.each([undefined, false])("doesn't set segment properties if standalone is falsy (%s)", standalone => { + const span = startSpan( + { + name: 'test span', + experimental: { standalone }, + }, + span => { + return span; + }, + ); + + const spanJson = spanToJSON(span); + expect(spanJson.is_segment).toBeUndefined(); + expect(spanJson.segment_id).toBeUndefined(); + }); + }); }); describe('startSpanManual', () => { diff --git a/packages/types/src/span.ts b/packages/types/src/span.ts index 066d8e7bf1f3..cc5cb45213b9 100644 --- a/packages/types/src/span.ts +++ b/packages/types/src/span.ts @@ -58,6 +58,8 @@ export interface SpanJSON { profile_id?: string; exclusive_time?: number; measurements?: Measurements; + is_segment?: boolean; + segment_id?: string; } // These are aligned with OpenTelemetry trace flags @@ -187,6 +189,15 @@ export interface SentrySpanArguments { * Timestamp in seconds (epoch time) indicating when the span ended. */ endTimestamp?: number | undefined; + + /** + * Set to `true` if this span should be sent as a standalone segment span + * as opposed to a transaction. + * + * @experimental this option is currently experimental and should only be + * used within SDK code. It might be removed or changed in the future. + */ + isStandalone?: boolean | undefined; } /** diff --git a/packages/types/src/startSpanOptions.ts b/packages/types/src/startSpanOptions.ts index 31874ffb6734..36e5f56355c9 100644 --- a/packages/types/src/startSpanOptions.ts +++ b/packages/types/src/startSpanOptions.ts @@ -26,4 +26,22 @@ export interface StartSpanOptions { /** Attributes for the span. */ attributes?: SpanAttributes; + + /** + * Experimental options without any stability guarantees. Use with caution! + */ + experimental?: { + /** + * If set to true, always start a standalone span which will be sent as a + * standalone segment span envelope instead of a transaction envelope. + * + * @internal this option is currently experimental and should only be + * used within SDK code. It might be removed or changed in the future. + * The payload ("envelope") of the resulting request sending the span to + * Sentry might change at any time. + * + * @hidden + */ + standalone?: boolean; + }; }