diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6d281fde0ac9..4cfeb4fab387 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,7 +127,7 @@ export type { FeatureFlag } from './featureFlags'; export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; export { getBreadcrumbLogLevelFromHttpStatusCode } from './utils-hoist/breadcrumb-log-level'; export { getComponentName, getLocationHref, htmlTreeAsString } from './utils-hoist/browser'; -export { dsnFromString, dsnToString, makeDsn } from './utils-hoist/dsn'; +export { dsnFromString, dsnToString, makeDsn, deriveOrgIdFromClient } from './utils-hoist/dsn'; // eslint-disable-next-line deprecation/deprecation export { SentryError } from './utils-hoist/error'; export { GLOBAL_OBJ } from './utils-hoist/worldwide'; @@ -216,6 +216,7 @@ export { extractTraceparentData, generateSentryTraceHeader, propagationContextFromHeaders, + shouldContinueTrace, } from './utils-hoist/tracing'; export { getSDKSource, isBrowserBundle } from './utils-hoist/env'; export type { SdkSource } from './utils-hoist/env'; diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 5f10f11db19c..e5a5389f394f 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -15,7 +15,7 @@ import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader, } from '../utils-hoist/baggage'; -import { extractOrgIdFromDsnHost } from '../utils-hoist/dsn'; +import { deriveOrgIdFromClient } from '../utils-hoist/dsn'; import { addNonEnumerableProperty } from '../utils-hoist/object'; import { getCapturedScopesOnSpan } from './utils'; @@ -45,14 +45,7 @@ export function freezeDscOnSpan(span: Span, dsc: Partial export function getDynamicSamplingContextFromClient(trace_id: string, client: Client): DynamicSamplingContext { const options = client.getOptions(); - const { publicKey: public_key, host } = client.getDsn() || {}; - - let org_id: string | undefined; - if (options.orgId) { - org_id = String(options.orgId); - } else if (host) { - org_id = extractOrgIdFromDsnHost(host); - } + const { publicKey: public_key } = client.getDsn() || {}; // Instead of conditionally adding non-undefined values, we add them and then remove them if needed // otherwise, the order of baggage entries changes, which "breaks" a bunch of tests etc. @@ -61,7 +54,7 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl release: options.release, public_key, trace_id, - org_id, + org_id: deriveOrgIdFromClient(client), }; client.emit('createDsc', dsc); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index a96159692ac3..15cc19dc4739 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -16,9 +16,10 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; +import { baggageHeaderToDynamicSamplingContext } from '../utils-hoist/baggage'; import { logger } from '../utils-hoist/logger'; import { generateTraceId } from '../utils-hoist/propagationContext'; -import { propagationContextFromHeaders } from '../utils-hoist/tracing'; +import { propagationContextFromHeaders, shouldContinueTrace } from '../utils-hoist/tracing'; import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanStart } from './logSpans'; import { sampleSpan } from './sampling'; @@ -216,6 +217,12 @@ export const continueTrace = ( const { sentryTrace, baggage } = options; + const incomingDsc = baggageHeaderToDynamicSamplingContext(baggage); + + if (shouldContinueTrace(getClient(), incomingDsc?.org_id)) { + return startNewTrace(callback); + } + return withScope(scope => { const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); scope.setPropagationContext(propagationContext); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 09dab550be4c..a308405a7174 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -320,6 +320,18 @@ export interface ClientOptions { expect(result).toEqual('aha'); }); + + describe('strictTraceContinuation', () => { + const creatOrgIdInDsn = (orgId: number) => { + vi.spyOn(client, 'getDsn').mockReturnValue({ + host: `o${orgId}.ingest.sentry.io`, + protocol: 'https', + projectId: 'projId', + }); + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('continues trace when org IDs match', () => { + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + + it('starts new trace when both SDK and baggage org IDs are set and do not match', () => { + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=456', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should start a new trace with a different trace ID + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + describe('when strictTraceContinuation is true', () => { + it('starts new trace when baggage org ID is missing', () => { + client.getOptions().strictTraceContinuation = true; + + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should start a new trace with a different trace ID + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + it('starts new trace when SDK org ID is missing', () => { + client.getOptions().strictTraceContinuation = true; + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should start a new trace with a different trace ID + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + it('continues trace when both org IDs are missing', () => { + client.getOptions().strictTraceContinuation = true; + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should continue the trace + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + }); + + describe('when strictTraceContinuation is false', () => { + it('continues trace when baggage org ID is missing', () => { + client.getOptions().strictTraceContinuation = false; + + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + + it('SDK org ID is missing', () => { + client.getOptions().strictTraceContinuation = false; + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + }); + }); }); describe('getActiveSpan', () => { diff --git a/packages/core/test/lib/tracing/utils.test.ts b/packages/core/test/lib/tracing/utils.test.ts new file mode 100644 index 000000000000..7fbb624644a8 --- /dev/null +++ b/packages/core/test/lib/tracing/utils.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { deriveOrgIdFromClient } from '../../../src/tracing/utils'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('deriveOrgIdFromClient', () => { + let client: TestClient; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('returns orgId from client options when available', () => { + client = new TestClient( + getDefaultTestClientOptions({ + orgId: '00222111', + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const result = deriveOrgIdFromClient(client); + expect(result).toBe('00222111'); + }); + + test('converts non-string orgId to string', () => { + client = new TestClient( + getDefaultTestClientOptions({ + orgId: 12345, + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const result = deriveOrgIdFromClient(client); + expect(result).toBe('12345'); + }); + + test('extracts orgId from DSN host when options.orgId is not available', () => { + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@o012300.example.com/1', + }), + ); + + const result = deriveOrgIdFromClient(client); + expect(result).toBe('012300'); + }); + + test('returns undefined when neither options.orgId nor DSN host are available', () => { + client = new TestClient(getDefaultTestClientOptions({})); + + const result = deriveOrgIdFromClient(client); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/core/test/utils-hoist/tracing.test.ts b/packages/core/test/utils-hoist/tracing.test.ts index 851ee7b109c4..4e23c7567c79 100644 --- a/packages/core/test/utils-hoist/tracing.test.ts +++ b/packages/core/test/utils-hoist/tracing.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, test } from 'vitest'; -import { extractTraceparentData, propagationContextFromHeaders } from '../../src/utils-hoist/tracing'; +import { + extractTraceparentData, + propagationContextFromHeaders, + shouldContinueTrace, +} from '../../src/utils-hoist/tracing'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; const EXAMPLE_SENTRY_TRACE = '12312012123120121231201212312012-1121201211212012-1'; const EXAMPLE_BAGGAGE = 'sentry-release=1.2.3,sentry-foo=bar,other=baz,sentry-sample_rand=0.42'; @@ -124,3 +129,65 @@ describe('extractTraceparentData', () => { expect(extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-x')).toBeUndefined(); }); }); + +describe('shouldContinueTrace', () => { + test('returns true when both baggage and SDK org IDs are undefined', () => { + const client = new TestClient(getDefaultTestClientOptions({})); + + const result = shouldContinueTrace(client, undefined); + expect(result).toBe(true); + }); + + test('returns true when org IDs match', () => { + const orgId = '123456'; + const client = new TestClient(getDefaultTestClientOptions({ orgId })); + + const result = shouldContinueTrace(client, orgId); + expect(result).toBe(true); + }); + + test('returns false when org IDs do not match', () => { + const client = new TestClient(getDefaultTestClientOptions({ orgId: '123456' })); + + const result = shouldContinueTrace(client, '654321'); + expect(result).toBe(false); + }); + + test('returns true when baggage org ID is undefined and strictTraceContinuation is false', () => { + const client = new TestClient(getDefaultTestClientOptions({ orgId: '123456', strictTraceContinuation: false })); + + const result = shouldContinueTrace(client, undefined); + expect(result).toBe(true); + }); + + test('returns true when SDK org ID is undefined and strictTraceContinuation is false', () => { + const client = new TestClient(getDefaultTestClientOptions({ strictTraceContinuation: false })); + + const result = shouldContinueTrace(client, '123456'); + expect(result).toBe(true); + }); + + test('returns false when baggage org ID is undefined and strictTraceContinuation is true', () => { + const client = new TestClient(getDefaultTestClientOptions({ orgId: '123456', strictTraceContinuation: true })); + + const result = shouldContinueTrace(client, undefined); + expect(result).toBe(false); + }); + + test('returns false when SDK org ID is undefined and strictTraceContinuation is true', () => { + const client = new TestClient(getDefaultTestClientOptions({ strictTraceContinuation: true })); + + const result = shouldContinueTrace(client, '123456'); + expect(result).toBe(false); + }); + + test('returns true when client is undefined', () => { + const result = shouldContinueTrace(undefined, '123456'); + expect(result).toBe(true); + }); + + test('returns true when both org IDs and client are undefined', () => { + const result = shouldContinueTrace(undefined, undefined); + expect(result).toBe(true); + }); +}); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json index c2b522d04ee1..0db9ad3bf16c 100644 --- a/packages/core/tsconfig.test.json +++ b/packages/core/tsconfig.test.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["DOM", "ES2018"], + "module": "ESNext", // support dynamic import() // should include all types from `./tsconfig.json` plus types for all test frameworks used "types": ["node"] diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index b5e493b31fd8..1c3b122ef466 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -15,9 +15,11 @@ import { parseBaggageHeader, propagationContextFromHeaders, SENTRY_BAGGAGE_KEY_PREFIX, + shouldContinueTrace, spanToJSON, stringMatchesSomePattern, } from '@sentry/core'; +import { baggageHeaderToDynamicSamplingContext } from '@sentry/core/src'; import { SENTRY_BAGGAGE_HEADER, SENTRY_TRACE_HEADER, SENTRY_TRACE_STATE_URL } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { getScopesFromContext, setScopesOnContext } from './utils/contextData'; @@ -210,10 +212,11 @@ function getContextWithRemoteActiveSpan( const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); const { traceId, parentSpanId, sampled, dsc } = propagationContext; + const incomingDsc = baggageHeaderToDynamicSamplingContext(baggage); // We only want to set the virtual span if we are continuing a concrete trace // Otherwise, we ignore the incoming trace here, e.g. if we have no trace headers - if (!parentSpanId) { + if (!parentSpanId || !shouldContinueTrace(getClient(), incomingDsc?.org_id)) { return ctx; } diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index e604d83bd4d8..c30377fe7763 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -259,7 +259,7 @@ export function continueTrace(options: Parameters[0 /** * Get the trace context for a given scope. - * We have a custom implemention here because we need an OTEL-specific way to get the span from a scope. + * We have a custom implementation here because we need an OTEL-specific way to get the span from a scope. */ export function getTraceContextForScope( client: Client,