Skip to content

feat(core): Implement strictTraceContinuation #16313

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '144 KB',
limit: '146 KB',
},
{
name: '@sentry/node - without tracing',
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export { instrumentOpenAiClient } from './utils/openai';
export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants';
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types';
export type { FeatureFlag } from './utils/featureFlags';

export {
_INTERNAL_copyFlagsFromScopeToEvent,
_INTERNAL_insertFlagToScope,
Expand Down Expand Up @@ -219,6 +220,7 @@ export {
extractTraceparentData,
generateSentryTraceHeader,
propagationContextFromHeaders,
shouldContinueTrace,
} from './utils/tracing';
export { getSDKSource, isBrowserBundle } from './utils/env';
export type { SdkSource } from './utils/env';
Expand Down
13 changes: 3 additions & 10 deletions packages/core/src/tracing/dynamicSamplingContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import type { DynamicSamplingContext } from '../types-hoist/envelope';
import type { Span } from '../types-hoist/span';
import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader } from '../utils/baggage';
import { extractOrgIdFromDsnHost } from '../utils/dsn';
import { extractOrgIdFromClient } from '../utils/dsn';
import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { addNonEnumerableProperty } from '../utils/object';
import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils';
Expand Down Expand Up @@ -42,14 +42,7 @@ export function freezeDscOnSpan(span: Span, dsc: Partial<DynamicSamplingContext>
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.
Expand All @@ -58,7 +51,7 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl
release: options.release,
public_key,
trace_id,
org_id,
org_id: extractOrgIdFromClient(client),
};

client.emit('createDsc', dsc);
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ import type { DynamicSamplingContext } from '../types-hoist/envelope';
import type { ClientOptions } from '../types-hoist/options';
import type { SentrySpanArguments, Span, SpanTimeInput } from '../types-hoist/span';
import type { StartSpanOptions } from '../types-hoist/startSpanOptions';
import { baggageHeaderToDynamicSamplingContext } from '../utils/baggage';
import { debug } from '../utils/debug-logger';
import { handleCallbackErrors } from '../utils/handleCallbackErrors';
import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { generateTraceId } from '../utils/propagationContext';
import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope';
import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils';
import { propagationContextFromHeaders } from '../utils/tracing';
import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing';
import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
import { logSpanStart } from './logSpans';
import { sampleSpan } from './sampling';
Expand Down Expand Up @@ -216,6 +217,12 @@ export const continueTrace = <V>(

const { sentryTrace, baggage } = options;

const client = getClient();
const incomingDsc = baggageHeaderToDynamicSamplingContext(baggage);
if (client && !shouldContinueTrace(client, incomingDsc?.org_id)) {
return startNewTrace(callback);
}

return withScope(scope => {
const propagationContext = propagationContextFromHeaders(sentryTrace, baggage);
scope.setPropagationContext(propagationContext);
Expand Down
19 changes: 16 additions & 3 deletions packages/core/src/types-hoist/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,23 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
tracePropagationTargets?: TracePropagationTargets;

/**
* The organization ID of the current SDK. The organization ID is a string containing only numbers. This ID is used to
* propagate traces to other Sentry services.
* If set to `true`, the SDK will only continue a trace if the `organization ID` of the incoming trace found in the
* `baggage` header matches the `organization ID` of the current Sentry client.
*
* The SDK tries to automatically extract the organization ID from the DSN. With this option, you can override it.
* The client's organization ID is extracted from the DSN or can be set with the `orgId` option.
*
* If the organization IDs do not match, the SDK will start a new trace instead of continuing the incoming one.
* This is useful to prevent traces of unknown third-party services from being continued in your application.
*
* @default false
*/
strictTraceContinuation?: boolean;

/**
* The organization ID for your Sentry project.
*
* The SDK will try to extract the organization ID from the DSN. If it cannot be found, or if you need to override it,
* you can provide the ID with this option. The organization ID is used for trace propagation and for features like `strictTraceContinuation`.
*/
orgId?: `${number}` | number;

Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/utils/dsn.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Client } from '../client';
import { DEBUG_BUILD } from '../debug-build';
import type { DsnComponents, DsnLike, DsnProtocol } from '../types-hoist/dsn';
import { consoleSandbox, debug } from './debug-logger';
Expand Down Expand Up @@ -129,6 +130,27 @@ export function extractOrgIdFromDsnHost(host: string): string | undefined {
return match?.[1];
}

/**
* Returns the organization ID of the client.
*
* The organization ID is extracted from the DSN. If the client options include a `orgId`, this will always take precedence.
*/
export function extractOrgIdFromClient(client: Client): string | undefined {
const options = client.getOptions();

const { host } = client.getDsn() || {};

let org_id: string | undefined;

if (options.orgId) {
org_id = String(options.orgId);
} else if (host) {
org_id = extractOrgIdFromDsnHost(host);
}

return org_id;
}

/**
* Creates a valid Sentry Dsn object, identifying a Sentry instance and project.
* @returns a valid DsnComponents object or `undefined` if @param from is an invalid DSN source
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/utils/tracing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { Client } from '../client';
import type { DynamicSamplingContext } from '../types-hoist/envelope';
import type { PropagationContext } from '../types-hoist/tracing';
import type { TraceparentData } from '../types-hoist/transaction';
import { debug } from '../utils/debug-logger';
import { baggageHeaderToDynamicSamplingContext } from './baggage';
import { extractOrgIdFromClient } from './dsn';
import { parseSampleRate } from './parseSampleRate';
import { generateSpanId, generateTraceId } from './propagationContext';

Expand Down Expand Up @@ -124,3 +127,38 @@ function getSampleRandFromTraceparentAndDsc(
return Math.random();
}
}

/**
* Determines whether a new trace should be continued based on the provided baggage org ID and the client's `strictTraceContinuation` option.
* If the trace should not be continued, a new trace will be started.
*
* The result is dependent on the `strictTraceContinuation` option in the client.
* See https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation
*/
export function shouldContinueTrace(client: Client, baggageOrgId?: string): boolean {
const clientOrgId = extractOrgIdFromClient(client);

// Case: baggage orgID and Client orgID don't match - always start new trace
if (baggageOrgId && clientOrgId && baggageOrgId !== clientOrgId) {
debug.log(
`Won't continue trace because org IDs don't match (incoming baggage: ${baggageOrgId}, SDK options: ${clientOrgId})`,
);
return false;
}

const strictTraceContinuation = client.getOptions().strictTraceContinuation || false; // default for `strictTraceContinuation` is `false`

if (strictTraceContinuation) {
// With strict continuation enabled, don't continue trace if:
// - Baggage has orgID, but Client doesn't have one
// - Client has orgID, but baggage doesn't have one
if ((baggageOrgId && !clientOrgId) || (!baggageOrgId && clientOrgId)) {
debug.log(
`Starting a new trace because strict trace continuation is enabled but one org ID is missing (incoming baggage: ${baggageOrgId}, Sentry client: ${clientOrgId})`,
);
return false;
}
}

return true;
}
145 changes: 145 additions & 0 deletions packages/core/test/lib/tracing/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1876,6 +1876,151 @@ describe('continueTrace', () => {

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', () => {
Expand Down
Loading
Loading