From 42ea6878277f6147054544e8609e04bbec470f4e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 11 Mar 2024 09:09:21 +0000 Subject: [PATCH] feat(core): Allow custom tracing implementations And do this for opentelemetry, instead of relying on extra exports. Also needed to move things around a bit to avoid circular dependency build issues. --- packages/core/src/asyncContext.ts | 20 ++ packages/core/src/exports.ts | 21 +- packages/core/src/index.ts | 2 +- packages/core/src/metrics/aggregator.ts | 2 +- .../core/src/metrics/browser-aggregator.ts | 2 +- packages/core/src/metrics/metric-summary.ts | 71 +++--- packages/core/src/tracing/errors.ts | 3 +- packages/core/src/tracing/idleSpan.ts | 9 +- packages/core/src/tracing/index.ts | 3 +- packages/core/src/tracing/measurement.ts | 4 +- packages/core/src/tracing/trace.ts | 56 ++++- packages/core/src/tracing/utils.ts | 9 - packages/core/src/utils/spanUtils.ts | 39 ++- packages/core/test/lib/scope.test.ts | 52 ---- .../tracing/dynamicSamplingContext.test.ts | 8 +- packages/core/test/lib/tracing/trace.test.ts | 228 +++++++++++++++++- packages/node-experimental/src/index.ts | 17 +- packages/node-experimental/src/types.ts | 8 +- .../opentelemetry/src/asyncContextStrategy.ts | 10 + packages/opentelemetry/src/trace.ts | 7 +- packages/opentelemetry/src/types.ts | 8 +- packages/types/src/span.ts | 44 +++- 22 files changed, 461 insertions(+), 162 deletions(-) diff --git a/packages/core/src/asyncContext.ts b/packages/core/src/asyncContext.ts index fa47ce8aa020..854b03ea9600 100644 --- a/packages/core/src/asyncContext.ts +++ b/packages/core/src/asyncContext.ts @@ -1,6 +1,8 @@ import type { Hub, Integration } from '@sentry/types'; import type { Scope } from '@sentry/types'; import { GLOBAL_OBJ } from '@sentry/utils'; +import type { startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './tracing/trace'; +import type { getActiveSpan } from './utils/spanUtils'; /** * @private Private API with no semver guarantees! @@ -42,6 +44,24 @@ export interface AsyncContextStrategy { * Get the currently active isolation scope. */ getIsolationScope: () => Scope; + + // OPTIONAL: Custom tracing methods + // These are used so that we can provide OTEL-based implementations + + /** Start an active span. */ + startSpan?: typeof startSpan; + + /** Start an inactive span. */ + startInactiveSpan?: typeof startInactiveSpan; + + /** Start an active manual span. */ + startSpanManual?: typeof startSpanManual; + + /** Get the currently active span. */ + getActiveSpan?: typeof getActiveSpan; + + /** Make a span the active span in the context of the callback. */ + withActiveSpan?: typeof withActiveSpan; } /** diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index eb9a95652792..54e158d4654a 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -10,18 +10,16 @@ import type { FinishedCheckIn, MonitorConfig, Primitive, - Scope as ScopeInterface, Session, SessionContext, SeverityLevel, - Span, TransactionContext, User, } from '@sentry/types'; import { GLOBAL_OBJ, isThenable, logger, timestampInSeconds, uuid4 } from '@sentry/utils'; import { DEFAULT_ENVIRONMENT } from './constants'; -import { getClient, getCurrentScope, getIsolationScope, withScope } from './currentScopes'; +import { getClient, getCurrentScope, getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { Hub } from './hub'; import { getCurrentHub } from './hub'; @@ -126,23 +124,6 @@ export function setUser(user: User | null): ReturnType { getIsolationScope().setUser(user); } -/** - * Forks the current scope and sets the provided span as active span in the context of the provided callback. Can be - * passed `null` to start an entirely new span tree. - * - * @param span Spans started in the context of the provided callback will be children of this span. If `null` is passed, - * spans started within the callback will not be attached to a parent span. - * @param callback Execution context in which the provided span will be active. Is passed the newly forked scope. - * @returns the value returned from the provided callback function. - */ -export function withActiveSpan(span: Span | null, callback: (scope: ScopeInterface) => T): T { - return withScope(scope => { - // eslint-disable-next-line deprecation/deprecation - scope.setSpan(span || undefined); - return callback(scope); - }); -} - /** * Starts a new `Transaction` and returns it. This is the entry point to manual tracing instrumentation. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60899647b26f..9c90fe4d31b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,7 +29,6 @@ export { startSession, endSession, captureSession, - withActiveSpan, addEventProcessor, } from './exports'; export { @@ -93,6 +92,7 @@ export { getSpanDescendants, getStatusMessage, getRootSpan, + getActiveSpan, } from './utils/spanUtils'; export { applySdkMetadata } from './utils/sdkMetadata'; export { DEFAULT_ENVIRONMENT } from './constants'; diff --git a/packages/core/src/metrics/aggregator.ts b/packages/core/src/metrics/aggregator.ts index 9116370efa52..169e40b42905 100644 --- a/packages/core/src/metrics/aggregator.ts +++ b/packages/core/src/metrics/aggregator.ts @@ -1,9 +1,9 @@ import type { Client, MeasurementUnit, MetricsAggregator as MetricsAggregatorBase, Primitive } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; +import { updateMetricSummaryOnActiveSpan } from '../utils/spanUtils'; import { DEFAULT_FLUSH_INTERVAL, MAX_WEIGHT, NAME_AND_TAG_KEY_NORMALIZATION_REGEX, SET_METRIC_TYPE } from './constants'; import { captureAggregateMetrics } from './envelope'; import { METRIC_MAP } from './instance'; -import { updateMetricSummaryOnActiveSpan } from './metric-summary'; import type { MetricBucket, MetricType } from './types'; import { getBucketKey, sanitizeTags } from './utils'; diff --git a/packages/core/src/metrics/browser-aggregator.ts b/packages/core/src/metrics/browser-aggregator.ts index c88611a32d82..7d599f5aeba8 100644 --- a/packages/core/src/metrics/browser-aggregator.ts +++ b/packages/core/src/metrics/browser-aggregator.ts @@ -1,9 +1,9 @@ import type { Client, MeasurementUnit, MetricsAggregator, Primitive } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; +import { updateMetricSummaryOnActiveSpan } from '../utils/spanUtils'; import { DEFAULT_BROWSER_FLUSH_INTERVAL, NAME_AND_TAG_KEY_NORMALIZATION_REGEX, SET_METRIC_TYPE } from './constants'; import { captureAggregateMetrics } from './envelope'; import { METRIC_MAP } from './instance'; -import { updateMetricSummaryOnActiveSpan } from './metric-summary'; import type { MetricBucket, MetricType } from './types'; import { getBucketKey, sanitizeTags } from './utils'; diff --git a/packages/core/src/metrics/metric-summary.ts b/packages/core/src/metrics/metric-summary.ts index 2be991297296..f1324def357d 100644 --- a/packages/core/src/metrics/metric-summary.ts +++ b/packages/core/src/metrics/metric-summary.ts @@ -2,7 +2,6 @@ import type { MeasurementUnit, Span } from '@sentry/types'; import type { MetricSummary } from '@sentry/types'; import type { Primitive } from '@sentry/types'; import { dropUndefinedKeys } from '@sentry/utils'; -import { getActiveSpan } from '../tracing/utils'; import type { MetricType } from './types'; /** @@ -40,9 +39,10 @@ export function getMetricSummaryJsonForSpan(span: Span): Record, bucketKey: string, ): void { - const span = getActiveSpan(); - if (span) { - const storage = getMetricStorageForSpan(span) || new Map(); + const storage = getMetricStorageForSpan(span) || new Map(); - const exportKey = `${metricType}:${sanitizedName}@${unit}`; - const bucketItem = storage.get(bucketKey); + const exportKey = `${metricType}:${sanitizedName}@${unit}`; + const bucketItem = storage.get(bucketKey); - if (bucketItem) { - const [, summary] = bucketItem; - storage.set(bucketKey, [ - exportKey, - { - min: Math.min(summary.min, value), - max: Math.max(summary.max, value), - count: (summary.count += 1), - sum: (summary.sum += value), - tags: summary.tags, - }, - ]); - } else { - storage.set(bucketKey, [ - exportKey, - { - min: value, - max: value, - count: 1, - sum: value, - tags, - }, - ]); - } - - if (!SPAN_METRIC_SUMMARY) { - SPAN_METRIC_SUMMARY = new WeakMap(); - } + if (bucketItem) { + const [, summary] = bucketItem; + storage.set(bucketKey, [ + exportKey, + { + min: Math.min(summary.min, value), + max: Math.max(summary.max, value), + count: (summary.count += 1), + sum: (summary.sum += value), + tags: summary.tags, + }, + ]); + } else { + storage.set(bucketKey, [ + exportKey, + { + min: value, + max: value, + count: 1, + sum: value, + tags, + }, + ]); + } - SPAN_METRIC_SUMMARY.set(span, storage); + if (!SPAN_METRIC_SUMMARY) { + SPAN_METRIC_SUMMARY = new WeakMap(); } + + SPAN_METRIC_SUMMARY.set(span, storage); } diff --git a/packages/core/src/tracing/errors.ts b/packages/core/src/tracing/errors.ts index f4f94b597eb0..de184c01391f 100644 --- a/packages/core/src/tracing/errors.ts +++ b/packages/core/src/tracing/errors.ts @@ -5,9 +5,8 @@ import { } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; -import { getRootSpan } from '../utils/spanUtils'; +import { getActiveSpan, getRootSpan } from '../utils/spanUtils'; import { SPAN_STATUS_ERROR } from './spanstatus'; -import { getActiveSpan } from './utils'; let errorsInstrumented = false; diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index c081d8a22b85..36d1474b39f2 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -5,11 +5,16 @@ import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '../semanticAttributes'; import { hasTracingEnabled } from '../utils/hasTracingEnabled'; -import { getSpanDescendants, removeChildSpanFromSpan, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; +import { + getActiveSpan, + getSpanDescendants, + removeChildSpanFromSpan, + spanTimeInputToSeconds, + spanToJSON, +} from '../utils/spanUtils'; import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; import { startInactiveSpan } from './trace'; -import { getActiveSpan } from './utils'; export const TRACING_DEFAULTS = { idleTimeout: 1_000, diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 7a095687c35c..e6f17a9f8911 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -4,7 +4,7 @@ export { SentrySpan } from './sentrySpan'; export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; export { Transaction } from './transaction'; // eslint-disable-next-line deprecation/deprecation -export { getActiveTransaction, getActiveSpan } from './utils'; +export { getActiveTransaction } from './utils'; export { setHttpStatus, getSpanStatusFromHttpCode, @@ -15,6 +15,7 @@ export { startInactiveSpan, startSpanManual, continueTrace, + withActiveSpan, } from './trace'; export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; export { setMeasurement } from './measurement'; diff --git a/packages/core/src/tracing/measurement.ts b/packages/core/src/tracing/measurement.ts index e6adfa446385..6945bba8aec8 100644 --- a/packages/core/src/tracing/measurement.ts +++ b/packages/core/src/tracing/measurement.ts @@ -1,7 +1,5 @@ import type { MeasurementUnit, Span, Transaction } from '@sentry/types'; -import { getRootSpan } from '../utils/spanUtils'; - -import { getActiveSpan } from './utils'; +import { getActiveSpan, getRootSpan } from '../utils/spanUtils'; /** * Adds a measurement to the current active transaction. diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index bc16a07962d8..e095b0df94a4 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -1,18 +1,26 @@ import type { Hub, Scope, Span, SpanTimeInput, StartSpanOptions, TransactionContext } from '@sentry/types'; import { dropUndefinedKeys, logger, tracingContextFromHeaders } from '@sentry/utils'; +import type { AsyncContextStrategy } from '../asyncContext'; +import { getMainCarrier } from '../asyncContext'; import { getCurrentScope, getIsolationScope, withScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { getCurrentHub } from '../hub'; +import { getAsyncContextStrategy, getCurrentHub } from '../hub'; import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasTracingEnabled } from '../utils/hasTracingEnabled'; -import { addChildSpanToSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; +import { + addChildSpanToSpan, + getActiveSpan, + spanIsSampled, + spanTimeInputToSeconds, + spanToJSON, +} from '../utils/spanUtils'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; import type { SentrySpan } from './sentrySpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; -import { getActiveSpan, setCapturedScopesOnSpan } from './utils'; +import { setCapturedScopesOnSpan } from './utils'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. @@ -26,6 +34,11 @@ import { getActiveSpan, setCapturedScopesOnSpan } from './utils'; * and the `span` returned from the callback will be undefined. */ export function startSpan(context: StartSpanOptions, callback: (span: Span) => T): T { + const acs = getAcs(); + if (acs.startSpan) { + return acs.startSpan(context, callback); + } + const spanContext = normalizeContext(context); return withScope(context.scope, scope => { @@ -73,6 +86,11 @@ export function startSpan(context: StartSpanOptions, callback: (span: Span) = * and the `span` returned from the callback will be undefined. */ export function startSpanManual(context: StartSpanOptions, callback: (span: Span, finish: () => void) => T): T { + const acs = getAcs(); + if (acs.startSpanManual) { + return acs.startSpanManual(context, callback); + } + const spanContext = normalizeContext(context); return withScope(context.scope, scope => { @@ -122,6 +140,11 @@ export function startSpanManual(context: StartSpanOptions, callback: (span: S * and the `span` returned from the callback will be undefined. */ export function startInactiveSpan(context: StartSpanOptions): Span { + const acs = getAcs(); + if (acs.startInactiveSpan) { + return acs.startInactiveSpan(context); + } + const spanContext = normalizeContext(context); // eslint-disable-next-line deprecation/deprecation const hub = getCurrentHub(); @@ -248,6 +271,28 @@ export const continueTrace: ContinueTrace = ( }); }; +/** + * Forks the current scope and sets the provided span as active span in the context of the provided callback. Can be + * passed `null` to start an entirely new span tree. + * + * @param span Spans started in the context of the provided callback will be children of this span. If `null` is passed, + * spans started within the callback will not be attached to a parent span. + * @param callback Execution context in which the provided span will be active. Is passed the newly forked scope. + * @returns the value returned from the provided callback function. + */ +export function withActiveSpan(span: Span | null, callback: (scope: Scope) => T): T { + const acs = getAcs(); + if (acs.withActiveSpan) { + return acs.withActiveSpan(span, callback); + } + + return withScope(scope => { + // eslint-disable-next-line deprecation/deprecation + scope.setSpan(span || undefined); + return callback(scope); + }); +} + function createChildSpanOrTransaction( hub: Hub, { @@ -340,3 +385,8 @@ function normalizeContext(context: StartSpanOptions): TransactionContext { return context; } + +function getAcs(): AsyncContextStrategy { + const carrier = getMainCarrier(); + return getAsyncContextStrategy(carrier); +} diff --git a/packages/core/src/tracing/utils.ts b/packages/core/src/tracing/utils.ts index 03a4c45c28cf..c22940508138 100644 --- a/packages/core/src/tracing/utils.ts +++ b/packages/core/src/tracing/utils.ts @@ -1,7 +1,6 @@ import type { Span, Transaction } from '@sentry/types'; import type { Scope } from '@sentry/types'; import { addNonEnumerableProperty } from '@sentry/utils'; -import { getCurrentScope } from '../currentScopes'; import type { Hub } from '../hub'; import { getCurrentHub } from '../hub'; @@ -23,14 +22,6 @@ export function getActiveTransaction(maybeHub?: Hub): T | // so it can be used in manual instrumentation without necessitating a hard dependency on @sentry/utils export { stripUrlQueryAndFragment } from '@sentry/utils'; -/** - * Returns the currently active span. - */ -export function getActiveSpan(): Span | undefined { - // eslint-disable-next-line deprecation/deprecation - return getCurrentScope().getSpan(); -} - const SCOPE_ON_START_SPAN_FIELD = '_sentryScope'; const ISOLATION_SCOPE_ON_START_SPAN_FIELD = '_sentryIsolationScope'; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index e7d9168ea2c3..9e311195b577 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -1,4 +1,6 @@ import type { + MeasurementUnit, + Primitive, Span, SpanAttributes, SpanJSON, @@ -13,7 +15,11 @@ import { generateSentryTraceHeader, timestampInSeconds, } from '@sentry/utils'; -import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary'; +import { getMainCarrier } from '../asyncContext'; +import { getCurrentScope } from '../currentScopes'; +import { getAsyncContextStrategy } from '../hub'; +import { getMetricSummaryJsonForSpan, updateMetricSummaryOnSpan } from '../metrics/metric-summary'; +import type { MetricType } from '../metrics/types'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; @@ -237,3 +243,34 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] { export function getRootSpan(span: SpanWithPotentialChildren): Span { return span[ROOT_SPAN_FIELD] || span; } + +/** + * Returns the currently active span. + */ +export function getActiveSpan(): Span | undefined { + const carrier = getMainCarrier(); + const acs = getAsyncContextStrategy(carrier); + if (acs.getActiveSpan) { + return acs.getActiveSpan(); + } + + // eslint-disable-next-line deprecation/deprecation + return getCurrentScope().getSpan(); +} + +/** + * Updates the metric summary on the currently active span + */ +export function updateMetricSummaryOnActiveSpan( + metricType: MetricType, + sanitizedName: string, + value: number, + unit: MeasurementUnit, + tags: Record, + bucketKey: string, +): void { + const span = getActiveSpan(); + if (span) { + updateMetricSummaryOnSpan(span, metricType, sanitizedName, value, unit, tags, bucketKey); + } +} diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 6ddb81086744..9dee993efd33 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -1,20 +1,13 @@ import type { Attachment, Breadcrumb, Client, Event, RequestSessionStatus } from '@sentry/types'; import { - addTracingExtensions, applyScopeDataToEvent, - getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, - setCurrentClient, setGlobalScope, - spanToJSON, - startInactiveSpan, - startSpan, withIsolationScope, } from '../../src'; -import { withActiveSpan } from '../../src/exports'; import { Scope } from '../../src/scope'; import { TestClient, getDefaultTestClientOptions } from '../mocks/client'; @@ -937,48 +930,3 @@ describe('isolation scope', () => { }); }); }); - -describe('withActiveSpan()', () => { - beforeAll(() => { - addTracingExtensions(); - }); - - beforeEach(() => { - const options = getDefaultTestClientOptions({ enableTracing: true }); - const client = new TestClient(options); - setCurrentClient(client); - client.init(); - }); - - it('should set the active span within the callback', () => { - expect.assertions(2); - const inactiveSpan = startInactiveSpan({ name: 'inactive-span' }); - - expect(getActiveSpan()).not.toBe(inactiveSpan); - - withActiveSpan(inactiveSpan, () => { - expect(getActiveSpan()).toBe(inactiveSpan); - }); - }); - - it('should create child spans when calling startSpan within the callback', () => { - const inactiveSpan = startInactiveSpan({ name: 'inactive-span' }); - - const parentSpanId = withActiveSpan(inactiveSpan, () => { - return startSpan({ name: 'child-span' }, childSpan => { - return spanToJSON(childSpan).parent_span_id; - }); - }); - - expect(parentSpanId).toBe(inactiveSpan.spanContext().spanId); - }); - - it('when `null` is passed, no span should be active within the callback', () => { - expect.assertions(1); - startSpan({ name: 'parent-span' }, () => { - withActiveSpan(null, () => { - expect(getActiveSpan()).toBeUndefined(); - }); - }); - }); -}); diff --git a/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts b/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts index 9c531907eac3..f38d3f3e26de 100644 --- a/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts +++ b/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts @@ -4,8 +4,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '../../../src'; -import { Transaction, getDynamicSamplingContextFromSpan, startInactiveSpan } from '../../../src/tracing'; -import { addTracingExtensions } from '../../../src/tracing'; +import { + Transaction, + addTracingExtensions, + getDynamicSamplingContextFromSpan, + startInactiveSpan, +} from '../../../src/tracing'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; describe('getDynamicSamplingContextFromSpan', () => { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 24171ea469d6..3395d9ee4133 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -1,26 +1,30 @@ -import type { Event, Span } from '@sentry/types'; +import type { Event, Span, StartSpanOptions } from '@sentry/types'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, + Scope, addTracingExtensions, getCurrentHub, getCurrentScope, getGlobalScope, getIsolationScope, + getMainCarrier, + setAsyncContextStrategy, setCurrentClient, spanIsSampled, spanToJSON, withScope, } from '../../../src'; +import { getAsyncContextStrategy } from '../../../src/hub'; import { SentrySpan, continueTrace, - getActiveSpan, startInactiveSpan, startSpan, startSpanManual, + withActiveSpan, } from '../../../src/tracing'; import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan'; -import { getSpanDescendants } from '../../../src/utils/spanUtils'; +import { getActiveSpan, getSpanDescendants } from '../../../src/utils/spanUtils'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; beforeAll(() => { @@ -42,6 +46,8 @@ describe('startSpan', () => { getIsolationScope().clear(); getGlobalScope().clear(); + setAsyncContextStrategy(undefined); + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); client = new TestClient(options); setCurrentClient(client); @@ -524,10 +530,42 @@ describe('startSpan', () => { }); }); }); + + it('uses implementation from ACS, if it exists', () => { + const staticSpan = new SentrySpan({ spanId: 'aha' }); + + const carrier = getMainCarrier(); + + const customFn = jest.fn((_options: StartSpanOptions, callback: (span: Span) => string) => { + callback(staticSpan); + return 'aha'; + }) as typeof startSpan; + + const acs = { + ...getAsyncContextStrategy(carrier), + startSpan: customFn, + }; + setAsyncContextStrategy(acs); + + const result = startSpan({ name: 'GET users/[id]' }, span => { + expect(span).toEqual(staticSpan); + return 'oho?'; + }); + + expect(result).toBe('aha'); + }); }); describe('startSpanManual', () => { beforeEach(() => { + addTracingExtensions(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); client = new TestClient(options); setCurrentClient(client); @@ -763,10 +801,42 @@ describe('startSpanManual', () => { }); }); }); + + it('uses implementation from ACS, if it exists', () => { + const staticSpan = new SentrySpan({ spanId: 'aha' }); + + const carrier = getMainCarrier(); + + const customFn = jest.fn((_options: StartSpanOptions, callback: (span: Span) => string) => { + callback(staticSpan); + return 'aha'; + }) as unknown as typeof startSpanManual; + + const acs = { + ...getAsyncContextStrategy(carrier), + startSpanManual: customFn, + }; + setAsyncContextStrategy(acs); + + const result = startSpanManual({ name: 'GET users/[id]' }, span => { + expect(span).toEqual(staticSpan); + return 'oho?'; + }); + + expect(result).toBe('aha'); + }); }); describe('startInactiveSpan', () => { beforeEach(() => { + addTracingExtensions(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); client = new TestClient(options); setCurrentClient(client); @@ -1018,10 +1088,37 @@ describe('startInactiveSpan', () => { expect(childSpans).toContain(innerSpan); }); }); + + it('uses implementation from ACS, if it exists', () => { + const staticSpan = new SentrySpan({ spanId: 'aha' }); + + const carrier = getMainCarrier(); + + const customFn = jest.fn((_options: StartSpanOptions) => { + return staticSpan; + }) as unknown as typeof startInactiveSpan; + + const acs = { + ...getAsyncContextStrategy(carrier), + startInactiveSpan: customFn, + }; + setAsyncContextStrategy(acs); + + const result = startInactiveSpan({ name: 'GET users/[id]' }); + expect(result).toBe(staticSpan); + }); }); describe('continueTrace', () => { beforeEach(() => { + addTracingExtensions(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); client = new TestClient(options); setCurrentClient(client); @@ -1214,6 +1311,131 @@ describe('continueTrace', () => { }); }); +describe('getActiveSpan', () => { + beforeEach(() => { + addTracingExtensions(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + + const options = getDefaultTestClientOptions({ tracesSampleRate: 0.0 }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + }); + + it('works without an active span on the scope', () => { + const span = getActiveSpan(); + expect(span).toBeUndefined(); + }); + + it('works with an active span on the scope', () => { + const activeSpan = new SentrySpan({ spanId: 'aha' }); + // eslint-disable-next-line deprecation/deprecation + getCurrentScope().setSpan(activeSpan); + + const span = getActiveSpan(); + expect(span).toBe(activeSpan); + }); + + it('uses implementation from ACS, if it exists', () => { + const staticSpan = new SentrySpan({ spanId: 'aha' }); + + const carrier = getMainCarrier(); + + const customFn = jest.fn(() => { + return staticSpan; + }) as typeof getActiveSpan; + + const acs = { + ...getAsyncContextStrategy(carrier), + getActiveSpan: customFn, + }; + setAsyncContextStrategy(acs); + + const result = getActiveSpan(); + expect(result).toBe(staticSpan); + }); +}); + +describe('withActiveSpan()', () => { + beforeAll(() => { + addTracingExtensions(); + }); + + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + + const options = getDefaultTestClientOptions({ enableTracing: true }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + }); + + it('should set the active span within the callback', () => { + expect.assertions(2); + const inactiveSpan = startInactiveSpan({ name: 'inactive-span' }); + + expect(getActiveSpan()).not.toBe(inactiveSpan); + + withActiveSpan(inactiveSpan, () => { + expect(getActiveSpan()).toBe(inactiveSpan); + }); + }); + + it('should create child spans when calling startSpan within the callback', () => { + const inactiveSpan = startInactiveSpan({ name: 'inactive-span' }); + + const parentSpanId = withActiveSpan(inactiveSpan, () => { + return startSpan({ name: 'child-span' }, childSpan => { + return spanToJSON(childSpan).parent_span_id; + }); + }); + + expect(parentSpanId).toBe(inactiveSpan.spanContext().spanId); + }); + + it('when `null` is passed, no span should be active within the callback', () => { + expect.assertions(1); + startSpan({ name: 'parent-span' }, () => { + withActiveSpan(null, () => { + expect(getActiveSpan()).toBeUndefined(); + }); + }); + }); + + it('uses implementation from ACS, if it exists', () => { + const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticScope = new Scope(); + + const carrier = getMainCarrier(); + + const customFn = jest.fn((_span: Span | null, callback: (scope: Scope) => string) => { + callback(staticScope); + return 'aha'; + }) as typeof withActiveSpan; + + const acs = { + ...getAsyncContextStrategy(carrier), + withActiveSpan: customFn, + }; + setAsyncContextStrategy(acs); + + const result = withActiveSpan(staticSpan, scope => { + expect(scope).toBe(staticScope); + return 'oho'; + }); + expect(result).toBe('aha'); + }); +}); + describe('span hooks', () => { beforeEach(() => { addTracingExtensions(); diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index 48ac9ecca58b..f7a101acdfe3 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -39,16 +39,9 @@ export { NodeClient } from './sdk/client'; export { getCurrentHub } from './sdk/hub'; export { cron } from './cron'; -export type { Span, NodeOptions } from './types'; +export type { NodeOptions } from './types'; -export { - startSpan, - startSpanManual, - startInactiveSpan, - getActiveSpan, - getRootSpan, - withActiveSpan, -} from '@sentry/opentelemetry'; +export { getRootSpan } from '@sentry/opentelemetry'; export { addRequestDataToEvent, @@ -108,6 +101,11 @@ export { captureSession, endSession, addIntegration, + startSpan, + startSpanManual, + startInactiveSpan, + getActiveSpan, + withActiveSpan, } from '@sentry/core'; export type { @@ -126,4 +124,5 @@ export type { Thread, Transaction, User, + Span, } from '@sentry/types'; diff --git a/packages/node-experimental/src/types.ts b/packages/node-experimental/src/types.ts index 19a825e176f4..d78e1761fd79 100644 --- a/packages/node-experimental/src/types.ts +++ b/packages/node-experimental/src/types.ts @@ -1,6 +1,6 @@ import type { Span as WriteableSpan } from '@opentelemetry/api'; -import type { ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; -import type { ClientOptions, Options, SamplingContext, Scope, TracePropagationTargets } from '@sentry/types'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { ClientOptions, Options, SamplingContext, Scope, Span, TracePropagationTargets } from '@sentry/types'; import type { NodeTransportOptions } from './transports'; @@ -104,6 +104,4 @@ export interface CurrentScopes { * Note that technically, the `Span` exported from `@opentelemwetry/sdk-trace-base` matches this, * but we cannot be 100% sure that we are actually getting such a span, so this type is more defensive. */ -export type AbstractSpan = WriteableSpan | ReadableSpan; - -export type { Span }; +export type AbstractSpan = WriteableSpan | ReadableSpan | Span; diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 95c4abaa3138..75169ab933ed 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -1,5 +1,6 @@ import * as api from '@opentelemetry/api'; import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import type { withActiveSpan as defaultWithActiveSpan } from '@sentry/core'; import type { Hub, Scope } from '@sentry/types'; import { @@ -8,8 +9,10 @@ import { SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, } from './constants'; import { getCurrentHub as _getCurrentHub } from './custom/getCurrentHub'; +import { startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './trace'; import type { CurrentScopes } from './types'; import { getScopesFromContext } from './utils/contextData'; +import { getActiveSpan } from './utils/getActiveSpan'; /** * Sets the async context strategy to use follow the OTEL context under the hood. @@ -112,5 +115,12 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void { withIsolationScope, getCurrentScope, getIsolationScope, + startSpan, + startSpanManual, + startInactiveSpan, + getActiveSpan, + // The types here don't fully align, because our own `Span` type is narrower + // than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around + withActiveSpan: withActiveSpan as typeof defaultWithActiveSpan, }); } diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index ea3ebea2df4e..80637001d809 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -64,7 +64,10 @@ export function startSpan(options: OpenTelemetrySpanContext, callback: (span: * * Note that you'll always get a span passed to the callback, it may just be a NonRecordingSpan if the span is not sampled. */ -export function startSpanManual(options: OpenTelemetrySpanContext, callback: (span: Span) => T): T { +export function startSpanManual( + options: OpenTelemetrySpanContext, + callback: (span: Span, finish: () => void) => T, +): T { const tracer = getTracer(); const { name } = options; @@ -79,7 +82,7 @@ export function startSpanManual(options: OpenTelemetrySpanContext, callback: _applySentryAttributesToSpan(span, options); return handleCallbackErrors( - () => callback(span), + () => callback(span, () => span.end()), () => { span.setStatus({ code: SpanStatusCode.ERROR }); }, diff --git a/packages/opentelemetry/src/types.ts b/packages/opentelemetry/src/types.ts index ce16499c2a25..30dd025a3116 100644 --- a/packages/opentelemetry/src/types.ts +++ b/packages/opentelemetry/src/types.ts @@ -1,6 +1,6 @@ import type { Span as WriteableSpan, SpanKind, Tracer } from '@opentelemetry/api'; -import type { BasicTracerProvider, ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; -import type { Scope, StartSpanOptions } from '@sentry/types'; +import type { BasicTracerProvider, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { Scope, Span, StartSpanOptions } from '@sentry/types'; export interface OpenTelemetryClient { tracer: Tracer; @@ -21,9 +21,7 @@ export interface OpenTelemetrySpanContext extends StartSpanOptions { * Note that technically, the `Span` exported from `@opentelemwetry/sdk-trace-base` matches this, * but we cannot be 100% sure that we are actually getting such a span, so this type is more defensive. */ -export type AbstractSpan = WriteableSpan | ReadableSpan; - -export type { Span }; +export type AbstractSpan = WriteableSpan | ReadableSpan | Span; export interface CurrentScopes { scope: Scope; diff --git a/packages/types/src/span.ts b/packages/types/src/span.ts index de925d25c123..509b1e5083ca 100644 --- a/packages/types/src/span.ts +++ b/packages/types/src/span.ts @@ -61,6 +61,44 @@ type TraceFlagNone = 0; type TraceFlagSampled = 1; export type TraceFlag = TraceFlagNone | TraceFlagSampled; +export interface TraceState { + /** + * Create a new TraceState which inherits from this TraceState and has the + * given key set. + * The new entry will always be added in the front of the list of states. + * + * @param key key of the TraceState entry. + * @param value value of the TraceState entry. + */ + set(key: string, value: string): TraceState; + /** + * Return a new TraceState which inherits from this TraceState but does not + * contain the given key. + * + * @param key the key for the TraceState entry to be removed. + */ + unset(key: string): TraceState; + /** + * Returns the value to which the specified key is mapped, or `undefined` if + * this map contains no mapping for the key. + * + * @param key with which the specified value is to be associated. + * @returns the value to which the specified key is mapped, or `undefined` if + * this map contains no mapping for the key. + */ + get(key: string): string | undefined; + /** + * Serializes the TraceState to a `list` as defined below. The `list` is a + * series of `list-members` separated by commas `,`, and a list-member is a + * key/value pair separated by an equals sign `=`. Spaces and horizontal tabs + * surrounding `list-members` are ignored. There can be a maximum of 32 + * `list-members` in a `list`. + * + * @returns the serialized string. + */ + serialize(): string; +} + export interface SpanContextData { /** * The ID of the trace that this span belongs to. It is worldwide unique @@ -80,7 +118,7 @@ export interface SpanContextData { /** * Only true if the SpanContext was propagated from a remote parent. */ - isRemote?: boolean; + isRemote?: boolean | undefined; /** * Trace flags to propagate. @@ -89,11 +127,11 @@ export interface SpanContextData { * sampled or not. When set, the least significant bit documents that the * caller may have recorded trace data. A caller who does not record trace * data out-of-band leaves this flag unset. - * We allow number here because otel also does, so we can't be stricter than them. */ traceFlags: TraceFlag | number; - // Note: we do not have traceState here, but this is optional in OpenTelemetry anyhow + /** In OpenTelemetry, this can be used to store trace state, which are basically key-value pairs. */ + traceState?: TraceState | undefined; } /**