Skip to content
Closed
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
19 changes: 9 additions & 10 deletions packages/core/src/integrations/mcp-server/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { MCP_NOTIFICATION_SPAN_NAME_FALLBACK, MCP_SERVER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpan } from '../../tracing/trace';
import { startSpan, withSegment } from '../../tracing/trace';
import { buildTransportAttributes, buildTypeSpecificAttributes } from './attributeExtraction';
import {
MCP_FUNCTION_ORIGIN_VALUE,
Expand Down Expand Up @@ -112,13 +112,14 @@ function createMcpSpan(config: McpSpanConfig): unknown {
const userInfo = Boolean(client?.getDataCollectionOptions().userInfo);
const attributes = filterMcpPiiFromSpanData(rawAttributes, userInfo) as Record<string, string | number>;

return startSpan(
{
name: spanName,
forceTransaction: true,
attributes,
},
callback,
return withSegment(() =>
startSpan(
{
name: spanName,
attributes,
},
callback,
),
);
}

Expand Down Expand Up @@ -186,7 +187,6 @@ export function buildMcpServerSpanConfig(
options?: ResolvedMcpOptions,
): {
name: string;
forceTransaction: boolean;
attributes: Record<string, string | number>;
} {
const { method } = jsonRpcMessage;
Expand All @@ -211,7 +211,6 @@ export function buildMcpServerSpanConfig(

return {
name: spanName,
forceTransaction: true,
attributes,
};
}
4 changes: 2 additions & 2 deletions packages/core/src/integrations/mcp-server/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { getIsolationScope, withIsolationScope } from '../../currentScopes';
import { withActiveSpan } from '../../tracing';
import { startInactiveSpan } from '../../tracing/trace';
import { startInactiveSpan, withSegment } from '../../tracing/trace';
import { isObjectLike } from '../../utils/is';
import { fill } from '../../utils/object';
import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes';
Expand Down Expand Up @@ -58,7 +58,7 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve

return withIsolationScope(isolationScope, () => {
const spanConfig = buildMcpServerSpanConfig(request, transport, extra as ExtraHandlerData, options);
const span = startInactiveSpan(spanConfig);
const span = withSegment(() => startInactiveSpan(spanConfig));

if (request.method === 'initialize' && messageSessionData) {
span.setAttributes({
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
} from './spanstatus';
export {
continueTrace,
withSegment,
withActiveSpan,
suppressTracing,
isTracingSuppressed,
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,41 @@ export const continueTrace = <V>(
});
};

/**
* Runs the callback in a scope that continues the current trace but carries no parent span, so a
* span started inside becomes the segment (root span) of that trace instead of a child span.
*
* This is the composable equivalent of the `forceTransaction` start-span option. The propagation
* context is written from the active span directly rather than through `continueTrace`, because
* `continueTrace` applies the trace continuation policy (`strictTraceContinuation`, org id
* matching) and would move the segment onto a new trace when a frozen DSC carries no org id.
*
* Mirrors what `registerPrepareSpanScope` in `@sentry/opentelemetry` does for a remote parent.
*/
export function withSegment<T>(callback: () => T): T {
const parentSpan = getActiveSpan();

// Without an active span the callback already starts a root span.
if (!parentSpan) {
return callback();
}

const { traceId, spanId } = parentSpan.spanContext();
const dsc = getDynamicSamplingContextFromSpan(parentSpan);
const sampleRand = Number(dsc.sample_rand);

return withScope(scope => {
scope.setPropagationContext({
traceId,
parentSpanId: spanId,
sampled: spanIsSampled(parentSpan),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Segment inherits false sampling decision

Medium Severity

withSegment always writes sampled: spanIsSampled(parentSpan) onto the forked propagation context. spanIsSampled is only true when trace flags are sampled, so a TwP placeholder or any parent with no sampling decision becomes false. That decision then rides on outgoing sentry-trace headers as -0, so downstream services inherit a negative sample and drop the trace. forceTransaction never mutated scope sampled, and registerPrepareSpanScope uses a three-state decision that can stay unset.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4d8bd97. Configure here.

dsc,
sampleRand: Number.isNaN(sampleRand) ? safeMathRandom() : sampleRand,
});
return withActiveSpan(null, callback);
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ describe('MCP Server Semantic Conventions', () => {

expect(startInactiveSpanSpy).toHaveBeenCalledWith({
name: 'tools/call get-weather',
forceTransaction: true,
attributes: {
'mcp.method.name': 'tools/call',
'mcp.tool.name': 'get-weather',
Expand Down Expand Up @@ -80,7 +79,6 @@ describe('MCP Server Semantic Conventions', () => {

expect(startInactiveSpanSpy).toHaveBeenCalledWith({
name: 'resources/read file:///docs/api.md',
forceTransaction: true,
attributes: {
'mcp.method.name': 'resources/read',
'mcp.resource.uri': 'file:///docs/api.md',
Expand Down Expand Up @@ -111,7 +109,6 @@ describe('MCP Server Semantic Conventions', () => {

expect(startInactiveSpanSpy).toHaveBeenCalledWith({
name: 'prompts/get analyze-code',
forceTransaction: true,
attributes: {
'mcp.method.name': 'prompts/get',
'mcp.prompt.name': 'analyze-code',
Expand Down Expand Up @@ -142,7 +139,6 @@ describe('MCP Server Semantic Conventions', () => {
expect(startSpanSpy).toHaveBeenCalledWith(
{
name: 'notifications/tools/list_changed',
forceTransaction: true,
attributes: {
'mcp.method.name': 'notifications/tools/list_changed',
'mcp.session.id': 'test-session-123',
Expand Down Expand Up @@ -179,7 +175,6 @@ describe('MCP Server Semantic Conventions', () => {
expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'tools/list',
forceTransaction: true,
attributes: expect.objectContaining({
'mcp.method.name': 'tools/list',
'mcp.request.id': 'req-4',
Expand Down Expand Up @@ -215,7 +210,6 @@ describe('MCP Server Semantic Conventions', () => {
expect(startSpanSpy).toHaveBeenCalledWith(
{
name: 'notifications/message',
forceTransaction: true,
attributes: {
'mcp.method.name': 'notifications/message',
'mcp.session.id': 'test-session-123',
Expand Down Expand Up @@ -416,7 +410,6 @@ describe('MCP Server Semantic Conventions', () => {
expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'tools/call weather-lookup',
forceTransaction: true,
attributes: expect.objectContaining({
'mcp.method.name': 'tools/call',
'mcp.tool.name': 'weather-lookup',
Expand Down Expand Up @@ -489,7 +482,6 @@ describe('MCP Server Semantic Conventions', () => {
expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'prompts/get code-review',
forceTransaction: true,
attributes: expect.objectContaining({
'mcp.method.name': 'prompts/get',
'mcp.prompt.name': 'code-review',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ describe('MCP Server Transport Instrumentation', () => {
expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'tools/call get-weather',
forceTransaction: true,
}),
);
});
Expand All @@ -137,7 +136,6 @@ describe('MCP Server Transport Instrumentation', () => {
expect(startSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'notifications/initialized',
forceTransaction: true,
}),
expect.any(Function),
);
Expand All @@ -158,7 +156,6 @@ describe('MCP Server Transport Instrumentation', () => {
expect(startSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'notifications/tools/list_changed',
forceTransaction: true,
}),
expect.any(Function),
);
Expand Down Expand Up @@ -270,7 +267,6 @@ describe('MCP Server Transport Instrumentation', () => {

expect(startInactiveSpanSpy).toHaveBeenCalledWith({
name: 'tools/call process-file',
forceTransaction: true,
attributes: {
'mcp.method.name': 'tools/call',
'mcp.tool.name': 'process-file',
Expand Down Expand Up @@ -419,7 +415,6 @@ describe('MCP Server Transport Instrumentation', () => {

expect(config).toEqual({
name: 'tools/call test-tool',
forceTransaction: true,
attributes: expect.objectContaining({
'mcp.method.name': 'tools/call',
'mcp.tool.name': 'test-tool',
Expand Down Expand Up @@ -871,7 +866,6 @@ describe('MCP Server Transport Instrumentation', () => {
expect(startSpanSpy).toHaveBeenCalledWith(
{
name: 'notifications/tools/list_changed',
forceTransaction: true,
attributes: {
'mcp.transport': 'StreamableHTTPServerTransport',
'network.transport': 'tcp',
Expand Down
71 changes: 71 additions & 0 deletions packages/core/test/lib/tracing/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SentrySpan,
suppressTracing,
withActiveSpan,
withSegment,
} from '../../../src/tracing';
import { startInactiveSpan, startSpan, startSpanManual } from '../../../src/tracing/trace';
import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan';
Expand Down Expand Up @@ -2605,6 +2606,76 @@ describe('startNewTrace', () => {
});
});

describe('withSegment', () => {
beforeEach(() => {
resetGlobals();
setAsyncContextStrategy(undefined);

const options = getDefaultTestClientOptions({ tracesSampleRate: 1 });
const client = new TestClient(options);
setCurrentClient(client);
client.init();
});

it('starts a segment on the trace of the active span instead of a child span', () => {
startSpan({ name: 'outer' }, outer => {
const segment = withSegment(() => startInactiveSpan({ name: 'segment' }));

expect(getRootSpan(segment)).toBe(segment);
expect(spanToJSON(segment).trace_id).toBe(spanToJSON(outer).trace_id);
expect(spanToJSON(segment).parent_span_id).toBe(outer.spanContext().spanId);
expect(getSpanDescendants(outer).map(span => spanToJSON(span).name)).toEqual(['outer']);
});
});

it('matches what `forceTransaction: true` produces', () => {
startSpan({ name: 'outer' }, () => {
const viaHelper = withSegment(() => startInactiveSpan({ name: 'segment' }));
const viaOption = startInactiveSpan({ name: 'segment', forceTransaction: true });

const helperJson = spanToJSON(viaHelper);
const optionJson = spanToJSON(viaOption);

expect(helperJson.trace_id).toBe(optionJson.trace_id);
expect(helperJson.parent_span_id).toBe(optionJson.parent_span_id);
expect(spanIsSampled(viaHelper)).toBe(spanIsSampled(viaOption));
expect(getDynamicSamplingContextFromSpan(viaHelper)).toEqual(getDynamicSamplingContextFromSpan(viaOption));
});
});

it('stays on the same trace when `strictTraceContinuation` would reject the frozen DSC', () => {
const options = getDefaultTestClientOptions({
dsn: 'https://username@domain/123',
tracesSampleRate: 1,
strictTraceContinuation: true,
orgId: '00222111',
});
const client = new TestClient(options);
setCurrentClient(client);
client.init();

// A trace continued without incoming baggage freezes a DSC that carries no `org_id`.
getCurrentScope().setPropagationContext({
traceId: '12345678901234567890123456789012',
sampleRand: 0.42,
dsc: {},
});

startSpan({ name: 'outer' }, outer => {
const segment = withSegment(() => startInactiveSpan({ name: 'segment' }));

expect(spanToJSON(segment).trace_id).toBe(spanToJSON(outer).trace_id);
});
});

it('runs the callback unchanged when there is no active span', () => {
const segment = withSegment(() => startInactiveSpan({ name: 'segment' }));

expect(getRootSpan(segment)).toBe(segment);
expect(spanToJSON(segment).parent_span_id).toBeUndefined();
});
});

describe('ignoreSpans (core path, streaming)', () => {
beforeEach(() => {
registerSpanErrorInstrumentation();
Expand Down
2 changes: 0 additions & 2 deletions packages/nestjs/src/integrations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,13 @@ export function getMiddlewareSpanOptions(
export function getEventSpanOptions(event: string): {
name: string;
attributes: Record<string, string>;
forceTransaction: boolean;
} {
return {
name: `event ${event}`,
attributes: {
[SENTRY_OP]: FUNCTION,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs',
},
forceTransaction: true,
};
}

Expand Down
20 changes: 11 additions & 9 deletions packages/nestjs/src/integrations/wrap-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope } from '@sentry/core';
import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope, withSegment } from '@sentry/core';
import type { AnyFn, ReflectWithMetadata } from './helpers';
import { getBullMQProcessSpanOptions, getEventSpanOptions, isWrapped, markWrapped } from './helpers';

Expand Down Expand Up @@ -99,14 +99,16 @@ export function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn
const wrapped = async function (this: unknown, ...args: unknown[]): Promise<unknown> {
const eventName = deriveEventName(wrapped, fallbackEvent);
return withIsolationScope(() =>
startSpan(getEventSpanOptions(eventName), async () => {
try {
return await handler.apply(this, args);
} catch (error) {
captureHandlerError(error, MECHANISM_EVENT);
throw error;
}
}),
withSegment(() =>
startSpan(getEventSpanOptions(eventName), async () => {
try {
return await handler.apply(this, args);
} catch (error) {
captureHandlerError(error, MECHANISM_EVENT);
throw error;
}
}),
),
);
};
return wrapped;
Expand Down
Loading
Loading