Skip to content

Commit b0909cd

Browse files
authored
feat(core): Implement strictTraceContinuation (#16313)
This implements `strictTraceContinuation` based on those docs: https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation closes #16291
1 parent f0147dc commit b0909cd

File tree

13 files changed

+349
-19
lines changed

13 files changed

+349
-19
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ module.exports = [
233233
import: createImport('init'),
234234
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
235235
gzip: true,
236-
limit: '144 KB',
236+
limit: '146 KB',
237237
},
238238
{
239239
name: '@sentry/node - without tracing',

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export { instrumentOpenAiClient } from './utils/openai';
122122
export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants';
123123
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types';
124124
export type { FeatureFlag } from './utils/featureFlags';
125+
125126
export {
126127
_INTERNAL_copyFlagsFromScopeToEvent,
127128
_INTERNAL_insertFlagToScope,
@@ -219,6 +220,7 @@ export {
219220
extractTraceparentData,
220221
generateSentryTraceHeader,
221222
propagationContextFromHeaders,
223+
shouldContinueTrace,
222224
} from './utils/tracing';
223225
export { getSDKSource, isBrowserBundle } from './utils/env';
224226
export type { SdkSource } from './utils/env';

packages/core/src/tracing/dynamicSamplingContext.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import type { DynamicSamplingContext } from '../types-hoist/envelope';
1111
import type { Span } from '../types-hoist/span';
1212
import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader } from '../utils/baggage';
13-
import { extractOrgIdFromDsnHost } from '../utils/dsn';
13+
import { extractOrgIdFromClient } from '../utils/dsn';
1414
import { hasSpansEnabled } from '../utils/hasSpansEnabled';
1515
import { addNonEnumerableProperty } from '../utils/object';
1616
import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils';
@@ -42,14 +42,7 @@ export function freezeDscOnSpan(span: Span, dsc: Partial<DynamicSamplingContext>
4242
export function getDynamicSamplingContextFromClient(trace_id: string, client: Client): DynamicSamplingContext {
4343
const options = client.getOptions();
4444

45-
const { publicKey: public_key, host } = client.getDsn() || {};
46-
47-
let org_id: string | undefined;
48-
if (options.orgId) {
49-
org_id = String(options.orgId);
50-
} else if (host) {
51-
org_id = extractOrgIdFromDsnHost(host);
52-
}
45+
const { publicKey: public_key } = client.getDsn() || {};
5346

5447
// Instead of conditionally adding non-undefined values, we add them and then remove them if needed
5548
// otherwise, the order of baggage entries changes, which "breaks" a bunch of tests etc.
@@ -58,7 +51,7 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl
5851
release: options.release,
5952
public_key,
6053
trace_id,
61-
org_id,
54+
org_id: extractOrgIdFromClient(client),
6255
};
6356

6457
client.emit('createDsc', dsc);

packages/core/src/tracing/trace.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ import type { DynamicSamplingContext } from '../types-hoist/envelope';
1111
import type { ClientOptions } from '../types-hoist/options';
1212
import type { SentrySpanArguments, Span, SpanTimeInput } from '../types-hoist/span';
1313
import type { StartSpanOptions } from '../types-hoist/startSpanOptions';
14+
import { baggageHeaderToDynamicSamplingContext } from '../utils/baggage';
1415
import { debug } from '../utils/debug-logger';
1516
import { handleCallbackErrors } from '../utils/handleCallbackErrors';
1617
import { hasSpansEnabled } from '../utils/hasSpansEnabled';
1718
import { parseSampleRate } from '../utils/parseSampleRate';
1819
import { generateTraceId } from '../utils/propagationContext';
1920
import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope';
2021
import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils';
21-
import { propagationContextFromHeaders } from '../utils/tracing';
22+
import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing';
2223
import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
2324
import { logSpanStart } from './logSpans';
2425
import { sampleSpan } from './sampling';
@@ -216,6 +217,12 @@ export const continueTrace = <V>(
216217

217218
const { sentryTrace, baggage } = options;
218219

220+
const client = getClient();
221+
const incomingDsc = baggageHeaderToDynamicSamplingContext(baggage);
222+
if (client && !shouldContinueTrace(client, incomingDsc?.org_id)) {
223+
return startNewTrace(callback);
224+
}
225+
219226
return withScope(scope => {
220227
const propagationContext = propagationContextFromHeaders(sentryTrace, baggage);
221228
scope.setPropagationContext(propagationContext);

packages/core/src/types-hoist/options.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,23 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
302302
tracePropagationTargets?: TracePropagationTargets;
303303

304304
/**
305-
* The organization ID of the current SDK. The organization ID is a string containing only numbers. This ID is used to
306-
* propagate traces to other Sentry services.
305+
* If set to `true`, the SDK will only continue a trace if the `organization ID` of the incoming trace found in the
306+
* `baggage` header matches the `organization ID` of the current Sentry client.
307307
*
308-
* The SDK tries to automatically extract the organization ID from the DSN. With this option, you can override it.
308+
* The client's organization ID is extracted from the DSN or can be set with the `orgId` option.
309+
*
310+
* If the organization IDs do not match, the SDK will start a new trace instead of continuing the incoming one.
311+
* This is useful to prevent traces of unknown third-party services from being continued in your application.
312+
*
313+
* @default false
314+
*/
315+
strictTraceContinuation?: boolean;
316+
317+
/**
318+
* The organization ID for your Sentry project.
319+
*
320+
* The SDK will try to extract the organization ID from the DSN. If it cannot be found, or if you need to override it,
321+
* you can provide the ID with this option. The organization ID is used for trace propagation and for features like `strictTraceContinuation`.
309322
*/
310323
orgId?: `${number}` | number;
311324

packages/core/src/utils/dsn.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Client } from '../client';
12
import { DEBUG_BUILD } from '../debug-build';
23
import type { DsnComponents, DsnLike, DsnProtocol } from '../types-hoist/dsn';
34
import { consoleSandbox, debug } from './debug-logger';
@@ -129,6 +130,27 @@ export function extractOrgIdFromDsnHost(host: string): string | undefined {
129130
return match?.[1];
130131
}
131132

133+
/**
134+
* Returns the organization ID of the client.
135+
*
136+
* The organization ID is extracted from the DSN. If the client options include a `orgId`, this will always take precedence.
137+
*/
138+
export function extractOrgIdFromClient(client: Client): string | undefined {
139+
const options = client.getOptions();
140+
141+
const { host } = client.getDsn() || {};
142+
143+
let org_id: string | undefined;
144+
145+
if (options.orgId) {
146+
org_id = String(options.orgId);
147+
} else if (host) {
148+
org_id = extractOrgIdFromDsnHost(host);
149+
}
150+
151+
return org_id;
152+
}
153+
132154
/**
133155
* Creates a valid Sentry Dsn object, identifying a Sentry instance and project.
134156
* @returns a valid DsnComponents object or `undefined` if @param from is an invalid DSN source

packages/core/src/utils/tracing.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import type { Client } from '../client';
12
import type { DynamicSamplingContext } from '../types-hoist/envelope';
23
import type { PropagationContext } from '../types-hoist/tracing';
34
import type { TraceparentData } from '../types-hoist/transaction';
5+
import { debug } from '../utils/debug-logger';
46
import { baggageHeaderToDynamicSamplingContext } from './baggage';
7+
import { extractOrgIdFromClient } from './dsn';
58
import { parseSampleRate } from './parseSampleRate';
69
import { generateSpanId, generateTraceId } from './propagationContext';
710

@@ -124,3 +127,38 @@ function getSampleRandFromTraceparentAndDsc(
124127
return Math.random();
125128
}
126129
}
130+
131+
/**
132+
* Determines whether a new trace should be continued based on the provided baggage org ID and the client's `strictTraceContinuation` option.
133+
* If the trace should not be continued, a new trace will be started.
134+
*
135+
* The result is dependent on the `strictTraceContinuation` option in the client.
136+
* See https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation
137+
*/
138+
export function shouldContinueTrace(client: Client, baggageOrgId?: string): boolean {
139+
const clientOrgId = extractOrgIdFromClient(client);
140+
141+
// Case: baggage orgID and Client orgID don't match - always start new trace
142+
if (baggageOrgId && clientOrgId && baggageOrgId !== clientOrgId) {
143+
debug.log(
144+
`Won't continue trace because org IDs don't match (incoming baggage: ${baggageOrgId}, SDK options: ${clientOrgId})`,
145+
);
146+
return false;
147+
}
148+
149+
const strictTraceContinuation = client.getOptions().strictTraceContinuation || false; // default for `strictTraceContinuation` is `false`
150+
151+
if (strictTraceContinuation) {
152+
// With strict continuation enabled, don't continue trace if:
153+
// - Baggage has orgID, but Client doesn't have one
154+
// - Client has orgID, but baggage doesn't have one
155+
if ((baggageOrgId && !clientOrgId) || (!baggageOrgId && clientOrgId)) {
156+
debug.log(
157+
`Starting a new trace because strict trace continuation is enabled but one org ID is missing (incoming baggage: ${baggageOrgId}, Sentry client: ${clientOrgId})`,
158+
);
159+
return false;
160+
}
161+
}
162+
163+
return true;
164+
}

packages/core/test/lib/tracing/trace.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,6 +1876,151 @@ describe('continueTrace', () => {
18761876

18771877
expect(result).toEqual('aha');
18781878
});
1879+
1880+
describe('strictTraceContinuation', () => {
1881+
const creatOrgIdInDsn = (orgId: number) => {
1882+
vi.spyOn(client, 'getDsn').mockReturnValue({
1883+
host: `o${orgId}.ingest.sentry.io`,
1884+
protocol: 'https',
1885+
projectId: 'projId',
1886+
});
1887+
};
1888+
1889+
afterEach(() => {
1890+
vi.clearAllMocks();
1891+
});
1892+
1893+
it('continues trace when org IDs match', () => {
1894+
creatOrgIdInDsn(123);
1895+
1896+
const scope = continueTrace(
1897+
{
1898+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
1899+
baggage: 'sentry-org_id=123',
1900+
},
1901+
() => {
1902+
return getCurrentScope();
1903+
},
1904+
);
1905+
1906+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
1907+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
1908+
});
1909+
1910+
it('starts new trace when both SDK and baggage org IDs are set and do not match', () => {
1911+
creatOrgIdInDsn(123);
1912+
1913+
const scope = continueTrace(
1914+
{
1915+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
1916+
baggage: 'sentry-org_id=456',
1917+
},
1918+
() => {
1919+
return getCurrentScope();
1920+
},
1921+
);
1922+
1923+
// Should start a new trace with a different trace ID
1924+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
1925+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
1926+
});
1927+
1928+
describe('when strictTraceContinuation is true', () => {
1929+
it('starts new trace when baggage org ID is missing', () => {
1930+
client.getOptions().strictTraceContinuation = true;
1931+
1932+
creatOrgIdInDsn(123);
1933+
1934+
const scope = continueTrace(
1935+
{
1936+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
1937+
baggage: 'sentry-environment=production',
1938+
},
1939+
() => {
1940+
return getCurrentScope();
1941+
},
1942+
);
1943+
1944+
// Should start a new trace with a different trace ID
1945+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
1946+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
1947+
});
1948+
1949+
it('starts new trace when SDK org ID is missing', () => {
1950+
client.getOptions().strictTraceContinuation = true;
1951+
1952+
const scope = continueTrace(
1953+
{
1954+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
1955+
baggage: 'sentry-org_id=123',
1956+
},
1957+
() => {
1958+
return getCurrentScope();
1959+
},
1960+
);
1961+
1962+
// Should start a new trace with a different trace ID
1963+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
1964+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
1965+
});
1966+
1967+
it('continues trace when both org IDs are missing', () => {
1968+
client.getOptions().strictTraceContinuation = true;
1969+
1970+
const scope = continueTrace(
1971+
{
1972+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
1973+
baggage: 'sentry-environment=production',
1974+
},
1975+
() => {
1976+
return getCurrentScope();
1977+
},
1978+
);
1979+
1980+
// Should continue the trace
1981+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
1982+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
1983+
});
1984+
});
1985+
1986+
describe('when strictTraceContinuation is false', () => {
1987+
it('continues trace when baggage org ID is missing', () => {
1988+
client.getOptions().strictTraceContinuation = false;
1989+
1990+
creatOrgIdInDsn(123);
1991+
1992+
const scope = continueTrace(
1993+
{
1994+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
1995+
baggage: 'sentry-environment=production',
1996+
},
1997+
() => {
1998+
return getCurrentScope();
1999+
},
2000+
);
2001+
2002+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
2003+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
2004+
});
2005+
2006+
it('SDK org ID is missing', () => {
2007+
client.getOptions().strictTraceContinuation = false;
2008+
2009+
const scope = continueTrace(
2010+
{
2011+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
2012+
baggage: 'sentry-org_id=123',
2013+
},
2014+
() => {
2015+
return getCurrentScope();
2016+
},
2017+
);
2018+
2019+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
2020+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
2021+
});
2022+
});
2023+
});
18792024
});
18802025

18812026
describe('getActiveSpan', () => {

0 commit comments

Comments
 (0)