Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@opentelemetry/api": "1.9.0",
"@opentelemetry/sdk-trace-node": "2.2.0",
"@opentelemetry/exporter-trace-otlp-http": "0.208.0",
"@opentelemetry/instrumentation-undici": "0.13.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import './instrument';

// Other imports below
import * as Sentry from '@sentry/node';
import { trace, type Span } from '@opentelemetry/api';
import express from 'express';

const app = express();
Expand Down Expand Up @@ -29,6 +30,23 @@ app.get('/test-transaction', function (req, res) {
});
});

app.get('/test-only-if-parent', function (req, res) {
// Remove the HTTP span from the context to simulate no parent span
Sentry.withActiveSpan(null, () => {
// This should NOT create a span because onlyIfParent is true and there's no parent
Sentry.startSpan({ name: 'test-only-if-parent', onlyIfParent: true }, () => {
// This custom OTel span SHOULD be created and exported
// This tests that custom OTel spans aren't suppressed when onlyIfParent triggers
const customTracer = trace.getTracer('custom-tracer');
customTracer.startActiveSpan('custom-span-with-only-if-parent', (span: Span) => {
span.end();
});
});

res.send({});
});
});

app.get('/test-error', async function (req, res) {
const exceptionId = Sentry.captureException(new Error('This is an error'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => {
const scopeSpans = json.resourceSpans?.[0]?.scopeSpans;
expect(scopeSpans).toBeDefined();

// Http server span & undici client spans are emitted,
// as well as the spans emitted via `Sentry.startSpan()`
// But our default node-fetch spans are not emitted
expect(scopeSpans.length).toEqual(3);
// Http server span & undici client spans are emitted.
// Sentry.startSpan() spans are NOT emitted (non-recording when tracing is disabled).
// Our default node-fetch spans are also not emitted.
expect(scopeSpans.length).toEqual(2);

const httpScopes = scopeSpans?.filter(scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-http');
const undiciScopes = scopeSpans?.filter(
Expand All @@ -47,43 +47,8 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => {

expect(httpScopes.length).toBe(1);

expect(startSpanScopes.length).toBe(1);
expect(startSpanScopes[0].spans.length).toBe(2);
expect(startSpanScopes[0].spans).toEqual([
{
traceId: expect.any(String),
spanId: expect.any(String),
parentSpanId: expect.any(String),
name: 'test-span',
kind: 1,
startTimeUnixNano: expect.any(String),
endTimeUnixNano: expect.any(String),
attributes: [],
droppedAttributesCount: 0,
events: [],
droppedEventsCount: 0,
status: { code: 0 },
links: [],
droppedLinksCount: 0,
flags: expect.any(Number),
},
{
traceId: expect.any(String),
spanId: expect.any(String),
name: 'test-transaction',
kind: 1,
startTimeUnixNano: expect.any(String),
endTimeUnixNano: expect.any(String),
attributes: [{ key: 'sentry.op', value: { stringValue: 'e2e-test' } }],
droppedAttributesCount: 0,
events: [],
droppedEventsCount: 0,
status: { code: 0 },
links: [],
droppedLinksCount: 0,
flags: expect.any(Number),
},
]);
// Sentry spans should not be exported when tracing is disabled
expect(startSpanScopes.length).toBe(0);

// Undici spans are emitted correctly
expect(undiciScopes.length).toBe(1);
Expand Down Expand Up @@ -164,3 +129,58 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => {
},
]);
});

test('Custom OTel spans work with onlyIfParent when no parent exists', async ({ baseURL }) => {
waitForTransaction('node-otel-without-tracing', transactionEvent => {
throw new Error('THIS SHOULD NEVER HAPPEN!');
});

// Ensure we send data to the OTLP endpoint
const otelPromise = waitForPlainRequest('node-otel-without-tracing-otel', data => {
const json = JSON.parse(data) as any;

const scopeSpans = json.resourceSpans?.[0]?.scopeSpans;

// Look for the custom span from our custom-tracer
const customScope = scopeSpans?.find(scopeSpan => scopeSpan.scope.name === 'custom-tracer');

return customScope && customScope.spans.some(span => span.name === 'custom-span-with-only-if-parent');
});

fetch(`${baseURL}/test-only-if-parent`);

const otelData = await otelPromise;

expect(otelData).toBeDefined();

const json = JSON.parse(otelData);
expect(json.resourceSpans.length).toBe(1);

const scopeSpans = json.resourceSpans?.[0]?.scopeSpans;
expect(scopeSpans).toBeDefined();

// Should have HTTP instrumentation span but NO Sentry span
const httpScopes = scopeSpans?.filter(scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-http');
const sentryScopes = scopeSpans?.filter(scopeSpan => scopeSpan.scope.name === '@sentry/node');
const customScopes = scopeSpans?.filter(scopeSpan => scopeSpan.scope.name === 'custom-tracer');

// HTTP span exists (from the incoming request)
expect(httpScopes.length).toBe(1);

// Sentry span should NOT exist (onlyIfParent + no parent = suppressed)
expect(sentryScopes.length).toBe(0);

// Custom OTel span SHOULD exist (this is what we're testing - the fix ensures this works)
expect(customScopes.length).toBe(1);
expect(customScopes[0].spans.length).toBe(1);
expect(customScopes[0].spans[0]).toMatchObject({
name: 'custom-span-with-only-if-parent',
kind: 1,
status: { code: 0 },
});

// Verify the custom span is recording (not suppressed)
const customSpan = customScopes[0].spans[0];
expect(customSpan.spanId).not.toBe('0000000000000000');
expect(customSpan.traceId).not.toBe('00000000000000000000000000000000');
});
101 changes: 60 additions & 41 deletions packages/opentelemetry/src/trace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Context, Span, SpanContext, SpanOptions, Tracer } from '@opentelemetry/api';
import { context, SpanStatusCode, trace, TraceFlags } from '@opentelemetry/api';
import { suppressTracing } from '@opentelemetry/core';
import { isTracingSuppressed, suppressTracing } from '@opentelemetry/core';
import type {
Client,
continueTrace as baseContinueTrace,
Expand All @@ -17,6 +17,7 @@ import {
getRootSpan,
getTraceContextFromScope,
handleCallbackErrors,
hasSpansEnabled,
SDK_VERSION,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
spanToJSON,
Expand All @@ -29,16 +30,12 @@ import { getSamplingDecision } from './utils/getSamplingDecision';
import { makeTraceState } from './utils/makeTraceState';

/**
* Wraps a function with a transaction/span and finishes the span after the function is done.
* The created span is the active span and will be used as parent by other spans created inside the function
* and can be accessed via `Sentry.getActiveSpan()`, as long as the function is executed while the scope is active.
*
* If you want to create a span that is not set as active, use {@link startInactiveSpan}.
*
* You'll always get a span passed to the callback,
* it may just be a non-recording span if the span is not sampled or if tracing is disabled.
* Internal helper for starting spans and manual spans. See {@link startSpan} and {@link startSpanManual} for the public APIs.
* @param options - The span context options
* @param callback - The callback to execute with the span
* @param autoEnd - Whether to automatically end the span after the callback completes
*/
export function startSpan<T>(options: OpenTelemetrySpanContext, callback: (span: Span) => T): T {
function _startSpan<T>(options: OpenTelemetrySpanContext, callback: (span: Span) => T, autoEnd: boolean): T {
const tracer = getTracer();

const { name, parentSpan: customParentSpan } = options;
Expand All @@ -53,6 +50,37 @@ export function startSpan<T>(options: OpenTelemetrySpanContext, callback: (span:

const spanOptions = getSpanOptions(options);

// If spans are not enabled, ensure we suppress tracing for the span creation
// but preserve the original context for the callback execution
// This ensures that we don't create spans when tracing is disabled which
// would otherwise be a problem for users that don't enable tracing but use
// custom OpenTelemetry setups.
if (!hasSpansEnabled()) {
const suppressedCtx = isTracingSuppressed(ctx) ? ctx : suppressTracing(ctx);

return context.with(suppressedCtx, () => {
return tracer.startActiveSpan(name, spanOptions, suppressedCtx, span => {
// Restore the original unsuppressed context for the callback execution
// so that custom OpenTelemetry spans maintain the correct context.
// We use activeCtx (not ctx) because ctx may be suppressed when onlyIfParent is true
// and no parent span exists. Using activeCtx ensures custom OTel spans are never
// inadvertently suppressed.
return context.with(activeCtx, () => {
return handleCallbackErrors(
() => callback(span),
() => {
// Only set the span status to ERROR when there wasn't any status set before, in order to avoid stomping useful span statuses
if (spanToJSON(span).status === undefined) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
},
autoEnd ? () => span.end() : undefined,
);
});
});
});
}

return tracer.startActiveSpan(name, spanOptions, ctx, span => {
return handleCallbackErrors(
() => callback(span),
Expand All @@ -62,15 +90,29 @@ export function startSpan<T>(options: OpenTelemetrySpanContext, callback: (span:
span.setStatus({ code: SpanStatusCode.ERROR });
}
},
() => span.end(),
autoEnd ? () => span.end() : undefined,
);
});
});
}

/**
* Wraps a function with a transaction/span and finishes the span after the function is done.
* The created span is the active span and will be used as parent by other spans created inside the function
* and can be accessed via `Sentry.getActiveSpan()`, as long as the function is executed while the scope is active.
*
* If you want to create a span that is not set as active, use {@link startInactiveSpan}.
*
* You'll always get a span passed to the callback,
* it may just be a non-recording span if the span is not sampled or if tracing is disabled.
*/
export function startSpan<T>(options: OpenTelemetrySpanContext, callback: (span: Span) => T): T {
return _startSpan(options, callback, true);
}

/**
* Similar to `Sentry.startSpan`. Wraps a function with a span, but does not finish the span
* after the function is done automatically. You'll have to call `span.end()` manually.
* after the function is done automatically. You'll have to call `span.end()` or the `finish` function passed to the callback manually.
*
* The created span is the active span and will be used as parent by other spans created inside the function
* and can be accessed via `Sentry.getActiveSpan()`, as long as the function is executed while the scope is active.
Expand All @@ -82,32 +124,7 @@ export function startSpanManual<T>(
options: OpenTelemetrySpanContext,
callback: (span: Span, finish: () => void) => T,
): T {
const tracer = getTracer();

const { name, parentSpan: customParentSpan } = options;

// If `options.parentSpan` is defined, we want to wrap the callback in `withActiveSpan`
const wrapper = getActiveSpanWrapper<T>(customParentSpan);

return wrapper(() => {
const activeCtx = getContext(options.scope, options.forceTransaction);
const shouldSkipSpan = options.onlyIfParent && !trace.getSpan(activeCtx);
const ctx = shouldSkipSpan ? suppressTracing(activeCtx) : activeCtx;

const spanOptions = getSpanOptions(options);

return tracer.startActiveSpan(name, spanOptions, ctx, span => {
return handleCallbackErrors(
() => callback(span, () => span.end()),
() => {
// Only set the span status to ERROR when there wasn't any status set before, in order to avoid stomping useful span statuses
if (spanToJSON(span).status === undefined) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
},
);
});
});
return _startSpan(options, span => callback(span, () => span.end()), false);
}

/**
Expand All @@ -130,13 +147,15 @@ export function startInactiveSpan(options: OpenTelemetrySpanContext): Span {
return wrapper(() => {
const activeCtx = getContext(options.scope, options.forceTransaction);
const shouldSkipSpan = options.onlyIfParent && !trace.getSpan(activeCtx);
const ctx = shouldSkipSpan ? suppressTracing(activeCtx) : activeCtx;
let ctx = shouldSkipSpan ? suppressTracing(activeCtx) : activeCtx;

const spanOptions = getSpanOptions(options);

const span = tracer.startSpan(name, spanOptions, ctx);
if (!hasSpansEnabled()) {
Copy link
Member

Choose a reason for hiding this comment

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

l: what about following to have the same return (so we don't have to touch two parts of the code in case the return changes (or a nested ternary):

let context = ctx;

if (!hasSpansEnabled()) {
  context = isTracingSuppressed(ctx) ? ctx : suppressTracing(ctx);
}

return tracer.startSpan(name, spanOptions, context);

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated.

ctx = isTracingSuppressed(ctx) ? ctx : suppressTracing(ctx);
}

return span;
return tracer.startSpan(name, spanOptions, ctx);
});
}

Expand Down
49 changes: 49 additions & 0 deletions packages/opentelemetry/test/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,55 @@ describe('trace (tracing disabled)', () => {
});
});

describe('trace (spans disabled)', () => {
beforeEach(() => {
// Initialize SDK without any tracing configuration (no tracesSampleRate or tracesSampler)
mockSdkInit({ tracesSampleRate: undefined, tracesSampler: undefined });
});

afterEach(async () => {
await cleanupOtel();
});

it('startSpan creates non-recording spans when hasSpansEnabled() === false', () => {
const val = startSpan({ name: 'outer' }, outerSpan => {
expect(outerSpan).toBeDefined();
expect(outerSpan.isRecording()).toBe(false);

// Nested spans should also be non-recording
return startSpan({ name: 'inner' }, innerSpan => {
expect(innerSpan).toBeDefined();
expect(innerSpan.isRecording()).toBe(false);
return 'test value';
});
});

expect(val).toEqual('test value');
});

it('startSpanManual creates non-recording spans when hasSpansEnabled() === false', () => {
const val = startSpanManual({ name: 'outer' }, outerSpan => {
expect(outerSpan).toBeDefined();
expect(outerSpan.isRecording()).toBe(false);

return startSpanManual({ name: 'inner' }, innerSpan => {
expect(innerSpan).toBeDefined();
expect(innerSpan.isRecording()).toBe(false);
return 'test value';
});
});

expect(val).toEqual('test value');
});

it('startInactiveSpan returns non-recording spans when hasSpansEnabled() === false', () => {
const span = startInactiveSpan({ name: 'test' });

expect(span).toBeDefined();
expect(span.isRecording()).toBe(false);
});
});

describe('trace (sampling)', () => {
afterEach(async () => {
await cleanupOtel();
Expand Down
Loading