Skip to content
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: 2 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Sentry registers a minimal OpenTelemetry-compatible tracer provider, context man

Spans go to Sentry. This is not a general OpenTelemetry pipeline: there is no exporter and no OTLP output. Sentry also refuses to register its provider if you already registered one of your own, logging a warning instead. If you want a real OpenTelemetry pipeline, use setup 3.

`@sentry/cloudflare/request` does not support this option. That entry point exists for runtimes that cannot enable the `nodejs_compat` compatibility flag (e.g. Shopify Oxygen) and sets up a reduced client without the OpenTelemetry tracer. Use the main `@sentry/cloudflare` entry point if you need setup 2.

##### 3. Your own OpenTelemetry, Sentry linked to it

Leave `enableOpenTelemetrySetup` unset or set it to `false`, turn Sentry tracing off, use your own OpenTelemetry setup, and add the Sentry `otlpIntegration()`:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { SpanKind, trace } from '@opentelemetry/api';
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
traceLifecycle: 'static',
// Deliberately left unset — the global tracer provider stays OTel's noop, so the spans below
// must not reach Sentry.
}),
{
async fetch() {
const tracer = trace.getTracer('integration-test-tracer');

const inactive = tracer.startSpan('otel inactive', { attributes: { 'test.attribute': 'inactive' } });
inactive.end();

await tracer.startActiveSpan(
'otel parent',
{ kind: SpanKind.CLIENT, attributes: { 'test.attribute': 'parent' } },
async otelParent => {
await Sentry.startSpan({ name: 'sentry child' }, async () => {
const otelGrandchild = tracer.startSpan('otel grandchild', {
attributes: { 'test.attribute': 'grandchild' },
});
otelGrandchild.end();
});

otelParent.end();
},
);

return new Response('ok');
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import type { Event } from '@sentry/core';
import { expect, it } from 'vitest';
import { SHORT_UUID_MATCHER } from '../../../../expect';
import { createRunner } from '../../../../runner';

it('drops spans emitted through @opentelemetry/api when `enableOpenTelemetrySetup` is not enabled', async ({
signal,
}) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as Event;

expect(transactionEvent.transaction).toBe('GET /');

// Only the Sentry span survives; it re-parents onto the request span because the noop OTel
// span it was nested under never became a real parent.
expect(transactionEvent.spans).toEqual([
{
data: { [SENTRY_ORIGIN]: 'manual' },
description: 'sentry child',
parent_span_id: transactionEvent.contexts?.trace?.span_id,
span_id: SHORT_UUID_MATCHER,
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: transactionEvent.contexts?.trace?.trace_id,
origin: 'manual',
},
]);
})
.start(signal);

await runner.makeRequest('get', '/');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "cloudflare-opentelemetry-tracer-disabled",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { SpanKind, trace } from '@opentelemetry/api';
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
traceLifecycle: 'static',
enableOpenTelemetrySetup: true,
}),
{
async fetch() {
const tracer = trace.getTracer('integration-test-tracer');

const inactive = tracer.startSpan('otel inactive', { attributes: { 'test.attribute': 'inactive' } });
inactive.end();

await tracer.startActiveSpan(
'otel parent',
{ kind: SpanKind.CLIENT, attributes: { 'test.attribute': 'parent' } },
async otelParent => {
await Sentry.startSpan({ name: 'sentry child' }, async () => {
const otelGrandchild = tracer.startSpan('otel grandchild', {
attributes: { 'test.attribute': 'grandchild' },
});
otelGrandchild.end();
});

otelParent.end();
},
);

// Neither span may attach to the finished `otel parent` once `startActiveSpan` has returned.
const otelAfter = tracer.startSpan('otel after active', { attributes: { 'test.attribute': 'after' } });
otelAfter.end();
await Sentry.startSpan({ name: 'sentry after active' }, async () => {});

return new Response('ok');
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { SENTRY_KIND, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import type { Event } from '@sentry/core';
import { expect, it } from 'vitest';
import { SHORT_UUID_MATCHER } from '../../../../expect';
import { createRunner } from '../../../../runner';

it('captures spans emitted through @opentelemetry/api and nests them with Sentry spans', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const event = envelope[1]?.[0]?.[1] as Event;
expect(event.transaction).toBe('GET /');

const requestSpanId = event.contexts?.trace?.span_id;
const traceId = event.contexts?.trace?.trace_id;

// Cloudflare installs no OTel context manager, so `context.active()` never carries a span.
// Neither tracer API passes an explicit context here, so both fall back to the Sentry active
// span — the incoming request span — and everything stays in the request transaction.
const otelParentSpanId = event.spans?.[1]?.span_id;
const sentryChildSpanId = event.spans?.[2]?.span_id;

// Spans are ordered by start time.
expect(event.spans).toEqual([
{
data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'inactive' },
description: 'otel inactive',
parent_span_id: requestSpanId,
span_id: SHORT_UUID_MATCHER,
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: traceId,
origin: 'manual',
},
{
data: { [SENTRY_ORIGIN]: 'manual', [SENTRY_KIND]: 'client', 'test.attribute': 'parent' },
description: 'otel parent',
parent_span_id: requestSpanId,
span_id: SHORT_UUID_MATCHER,
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: traceId,
origin: 'manual',
},
// Below the OTel parent the two APIs interleave correctly: the tracer publishes its active
// span on the Sentry scope, so the Sentry span picks it up as parent and the next OTel span
// picks up the Sentry one in turn.
{
data: { [SENTRY_ORIGIN]: 'manual' },
description: 'sentry child',
parent_span_id: otelParentSpanId,
span_id: SHORT_UUID_MATCHER,
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: traceId,
origin: 'manual',
},
{
data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'grandchild' },
description: 'otel grandchild',
parent_span_id: sentryChildSpanId,
span_id: SHORT_UUID_MATCHER,
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: traceId,
origin: 'manual',
},
// Without an OTel context manager `context.with` cannot restore anything, so the tracer has to
// fork the scope itself. Otherwise the finished `otel parent` would stay active and both of
// these would hang off it instead of the request span.
{
data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'after' },
description: 'otel after active',
parent_span_id: requestSpanId,
span_id: SHORT_UUID_MATCHER,
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: traceId,
origin: 'manual',
},
{
data: { [SENTRY_ORIGIN]: 'manual' },
description: 'sentry after active',
parent_span_id: requestSpanId,
span_id: SHORT_UUID_MATCHER,
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: traceId,
origin: 'manual',
},
]);
})
.start(signal);

await runner.makeRequest('get', '/');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "cloudflare-opentelemetry-tracer",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
}
2 changes: 2 additions & 0 deletions docs/migration/v11-end-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,8 @@ Attribute availability remains runtime-dependent. For example, browser and Worke

- The `code.filepath` and `code.function` span attributes on `ui.long_animation_frame` spans were renamed to `code.file.path` and `code.function.name`.
- The `fs_error` span attribute on `file` spans was replaced by `error.type`. The value changed from the full error message to just the syscall's error code instead (`ENOENT`).
- The Cloudflare-specific `sentry.cloudflare_tracer` span attribute is no longer set. `@sentry/cloudflare` now creates spans through the shared `SentryTracerProvider`, so spans emitted via `@opentelemetry/api` no longer carry a marker distinguishing them from other Sentry spans.
- Span attributes now use the shared `@sentry/conventions` package under the hood.

#### Attribute constants

Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@opentelemetry/api": "^1.9.1",
"@sentry/conventions": "^0.20.0",
"@sentry/core": "10.67.0",
"@sentry/opentelemetry": "10.67.0",
"@sentry/server-utils": "10.67.0",
"magic-string": "~0.30.21"
},
Expand Down
10 changes: 0 additions & 10 deletions packages/cloudflare/src/baseSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { makeFlushLock } from './flush';
import { fetchIntegration } from './integrations/fetch';
import { httpServerIntegration } from './integrations/httpServer';
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from './integrations/spotlight';
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
import { makeCloudflareTransport } from './transport';
import { defaultStackParser } from './vendor/stacktrace';

Expand Down Expand Up @@ -94,9 +93,6 @@ export function initWithDefaultIntegrations(
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
integrations: getIntegrationsToSetup(options),
transport: options.transport || makeCloudflareTransport,
// Like most Node-based SDKs, Cloudflare defaults to running without a Sentry OpenTelemetry tracer
// provider. Scope isolation is handled by the entrypoint wrappers' AsyncLocalStorage strategy.
enableOpenTelemetrySetup: options.enableOpenTelemetrySetup ?? false,
flushLock,
};

Expand All @@ -110,12 +106,6 @@ export function initWithDefaultIntegrations(
}
/*! rollup-include-development-only-end */

// Opt-in only: when `enableOpenTelemetrySetup` is `true`, set up a custom trace provider so spans
// emitted via `@opentelemetry/api` are captured by Sentry. See the option's docs for the caveats.
if (clientOptions.enableOpenTelemetrySetup) {
setupOpenTelemetryTracer();
}

const client = initAndBind(CloudflareClient, clientOptions) as CloudflareClient;

// An instrumented module that first evaluates AFTER this init (e.g. a driver
Expand Down
74 changes: 2 additions & 72 deletions packages/cloudflare/src/opentelemetry/tracer.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,10 @@
import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
import { startInactiveSpan, startSpanManual } from '@sentry/core';
import { SentryTracerProvider } from '@sentry/opentelemetry';

/**
* Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans.
* This is not perfect but handles easy/common use cases.
*/
export function setupOpenTelemetryTracer(): void {
trace.setGlobalTracerProvider(new SentryCloudflareTraceProvider());
}

class SentryCloudflareTraceProvider implements TracerProvider {
private readonly _tracers: Map<string, Tracer> = new Map();

public getTracer(name: string, version?: string, options?: { schemaUrl?: string }): Tracer {
const key = `${name}@${version || ''}:${options?.schemaUrl || ''}`;
if (!this._tracers.has(key)) {
this._tracers.set(key, new SentryCloudflareTracer());
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this._tracers.get(key)!;
}
}

class SentryCloudflareTracer implements Tracer {
public startSpan(name: string, options?: SpanOptions): Span {
return startInactiveSpan({
...options,
name,
attributes: {
...options?.attributes,
'sentry.cloudflare_tracer': true,
},
});
}

/**
* NOTE: This does not handle `context` being passed in. It will always put spans on the current scope.
*/
public startActiveSpan<F extends (span: Span) => unknown>(name: string, fn: F): ReturnType<F>;
public startActiveSpan<F extends (span: Span) => unknown>(name: string, options: SpanOptions, fn: F): ReturnType<F>;
public startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: SpanOptions,
context: Context,
fn: F,
): ReturnType<F>;
public startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: unknown,
context?: unknown,
fn?: F,
): ReturnType<F> {
const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions;

const spanOpts = {
...opts,
name,
attributes: {
...opts.attributes,
'sentry.cloudflare_tracer': true,
},
};

const callback = (
typeof options === 'function'
? options
: typeof context === 'function'
? context
: typeof fn === 'function'
? fn
: () => {}
) as F;

// In OTEL the semantic matches `startSpanManual` because spans are not auto-ended
return startSpanManual(spanOpts, callback) as ReturnType<F>;
}
trace.setGlobalTracerProvider(new SentryTracerProvider());
}
6 changes: 5 additions & 1 deletion packages/cloudflare/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ type InitSdk = (options: CloudflareOptions) => CloudflareClient | undefined;
* getDefaultIntegrations(options)` in `options` to get the full set instead.
*/
export function wrapRequestHandler(
wrapperOptions: RequestHandlerWrapperOptions,
wrapperOptions: Omit<RequestHandlerWrapperOptions, 'options'> & {
// `enableOpenTelemetrySetup` is only honored by `init` from `sdk.ts`; this entry point
// initializes the SDK via `initBaseSdk`, where setting it would have no effect.
options: Omit<CloudflareOptions, 'enableOpenTelemetrySetup'>;
},
handler: (...args: unknown[]) => Response | Promise<Response>,
): Promise<Response> {
return wrapRequestHandlerWithInit(wrapperOptions, handler, initBaseSdk);
Comment on lines 55 to 63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The wrapRequestHandler no longer accepts enableOpenTelemetrySetup, but the SvelteKit integration still passes it. This will silently disable SvelteKit's OpenTelemetry tracing on Cloudflare.
Severity: MEDIUM

Suggested Fix

Update the SvelteKit integration to handle this change. Either switch to using the main init function from sdk.ts which still honors enableOpenTelemetrySetup, or remove the enableOpenTelemetrySetup: true setting from the options passed to wrapRequestHandler.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/cloudflare/src/request.ts#L54-L62

Potential issue: The `wrapRequestHandler` function's options type was changed to
`Omit<CloudflareOptions, 'enableOpenTelemetrySetup'>`, explicitly removing support for
this option from the `/request` entrypoint. However, the SvelteKit integration was not
updated and continues to pass `enableOpenTelemetrySetup: true` to this function. While
this is a TypeScript type error, at runtime the property is silently ignored. This
causes a functional regression where SvelteKit's own OpenTelemetry spans (for Kit
tracing) will no longer be captured by Sentry when deployed on Cloudflare, as the
necessary tracer setup is skipped.

Also affects:

  • packages/cloudflare/src/baseSdk.ts:18~23

Did we get this right? 👍 / 👎 to inform future reviews.

Expand Down
Loading
Loading