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 11 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 { deriveOrgIdFromClient } from '../utils/dsn';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: why did we change this? IMHO derive is not easier to understand/grasp as extract...? 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with change, I mean re-word :D I would just go with extractOrgIdFromClient or something like this?

Copy link
Member Author

@s1gr1d s1gr1d Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a different function :D
I extracted the logic of getting the org ID.

extractOrgIdFromDsnHost is still available: https://github.com/getsentry/sentry-javascript/pull/16313/files#diff-b6e0f71bb87e888ddb45cd78dcf2cb7efdf9c80b6d596c0224648b1f78bdcb29R148

The one function is doing extractOrIdFromDsnHost, which is really just looking at the host string and extracting it from there. And deriveOrgIdFromClient is looking at different cues (either the org ID or the host on the client) to get the org ID.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, right, that makes sense of course - what I mean is that I think derive is a non-ideal term/prefix for the function name :D we do not use it anywhere in the code, rather we always use e.g. extractXX or getXX or something like this, so I'd rather use something along these lines for consistency - it is a different method of course :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah got you, that makes sense of course 👍

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: deriveOrgIdFromClient(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 incomingDsc = baggageHeaderToDynamicSamplingContext(baggage);

if (!shouldContinueTrace(getClient(), incomingDsc?.org_id)) {
return startNewTrace(callback);
}

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

/**
* Controls whether trace continuation should be strict about matching organization IDs.
*
* When set to `true`, the SDK will only continue a trace if the organization ID in the incoming baggage
* matches the organization ID of the current SDK (extracted from the DSN).
*
* If there is no match, the SDK will start a new trace instead of continuing the incoming one.
*
* @default false
*/
strictTraceContinuation?: boolean;

/**
* 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.
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 deriveOrgIdFromClient(client: Client | undefined): string | undefined {
const options = client?.getOptions();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: I would re-organize this a bit and either:

  1. Enforce that client is not undefined - imho this is likely the nicer API, and then check at call-site if the client is undefined.
  2. Or else check at the top of the function if client is defined and return undefined early, avoiding repeated optional chaining below.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re 1: it is a bit weird to have an API deriveXXFromY and allow no Y to be passed in :D


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

let org_id: string | undefined;

if (options?.orgId) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (options?.orgId) {
if (options.orgId) {

I think this always exists now?

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 { deriveOrgIdFromClient } 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 client and baggage org ID.
* 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 | undefined, baggageOrgId?: string): boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, do we need to allow empty client? IMHO we never want to continue a trace if we have no client, so we can probably just check this at call site as well?

const sdkOptionOrgId = deriveOrgIdFromClient(client);

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

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

if (strictTraceContinuation) {
// With strict continuation enabled, start new trace if:
// - Baggage has orgID, but SDK doesn't have one
// - SDK has orgID, but baggage doesn't have one
if ((baggageOrgId && !sdkOptionOrgId) || (!baggageOrgId && sdkOptionOrgId)) {
debug.log(
`Starting a new trace because strict trace continuation is enabled and one org ID is missing (incoming baggage: ${baggageOrgId}, SDK options: ${sdkOptionOrgId})`,
);
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