From 62f78f92c117952e9107626233c71fd1185f3fa9 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 26 Aug 2026 11:51:09 +0300 Subject: [PATCH] fix(sveltekit): Register the OpenTelemetry tracer provider on Cloudflare `initCloudflareSentryHandle` sets `enableOpenTelemetrySetup: true`, but wrapped the request with `wrapRequestHandler` from `@sentry/cloudflare/request`, which initializes the SDK via `initBaseSdk` and ignores that option. Kit tracing spans emitted via `@opentelemetry/api` were therefore dropped. Add an internal, `init`-backed `_INTERNAL_wrapRequestHandler` to the main `@sentry/cloudflare` entry point and use it from SvelteKit. The `/request` entry point stays OTel-free on purpose, as it has to work without `nodejs_compat`. Integration tests pin both behaviours in workerd. Co-Authored-By: Claude Fable 5 --- .../internal-request-handler/index.ts | 40 +++++++++++ .../internal-request-handler/test.ts | 60 ++++++++++++++++ .../internal-request-handler/wrangler.jsonc | 6 ++ .../subpath-request-handler/index.ts | 31 ++++++++ .../subpath-request-handler/test.ts | 19 +++++ .../subpath-request-handler/wrangler.jsonc | 5 ++ packages/cloudflare/src/index.ts | 2 +- packages/cloudflare/src/sdk.ts | 18 +++++ .../cloudflare/test/opentelemetry.test.ts | 70 ++++++++++++++++++- packages/sveltekit/src/worker/cloudflare.ts | 7 +- .../sveltekit/test/worker/cloudflare.test.ts | 9 ++- 11 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts new file mode 100644 index 000000000000..3c5e20237fa0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts @@ -0,0 +1,40 @@ +import { trace } from '@opentelemetry/api'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// Mirrors how the SvelteKit SDK wraps a request on Cloudflare: the `init`-backed wrapper from the +// main entry point, with the OpenTelemetry tracer provider enabled so Kit tracing spans (emitted via +// `startActiveSpan`) end up in the request transaction. +export default { + async fetch(request, env, ctx) { + return Sentry._INTERNAL_wrapRequestHandler( + { + options: { + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1, + enableOpenTelemetrySetup: true, + }, + request, + context: ctx, + }, + async () => { + const tracer = trace.getTracer('sveltekit'); + + await tracer.startActiveSpan('sveltekit.handle.root', async handleSpan => { + await Sentry.startSpan({ name: 'sentry child' }, async () => { + const resolveSpan = tracer.startSpan('sveltekit.resolve', { attributes: { 'http.route': '/' } }); + resolveSpan.end(); + }); + + handleSpan.end(); + }); + + return new Response('ok'); + }, + ); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts new file mode 100644 index 000000000000..7354baf802e2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts @@ -0,0 +1,60 @@ +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('captures spans emitted through @opentelemetry/api inside _INTERNAL_wrapRequestHandler', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /'); + expect(event.contexts?.trace?.op).toBe('http.server'); + + const requestSpanId = event.contexts?.trace?.span_id; + const traceId = event.contexts?.trace?.trace_id; + const handleSpanId = event.spans?.[0]?.span_id; + const sentryChildSpanId = event.spans?.[1]?.span_id; + + // Spans are ordered by start time. + expect(event.spans).toEqual([ + { + data: { [SENTRY_ORIGIN]: 'manual' }, + description: 'sveltekit.handle.root', + 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 child', + parent_span_id: handleSpanId, + 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', 'http.route': '/' }, + description: 'sveltekit.resolve', + 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', + }, + ]); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/wrangler.jsonc new file mode 100644 index 000000000000..82b512a2ea88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "cloudflare-opentelemetry-tracer-internal-request-handler", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts new file mode 100644 index 000000000000..1cfcb50fb0b5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts @@ -0,0 +1,31 @@ +import { trace } from '@opentelemetry/api'; +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { wrapRequestHandler } from '@sentry/cloudflare/request'; + +interface Env { + SENTRY_DSN: string; +} + +// The `/request` subpath has to work without `nodejs_compat`, so it never registers the +// OpenTelemetry tracer provider, even when `enableOpenTelemetrySetup` is passed. The option is +// omitted from the subpath's type on purpose; it is smuggled in here to pin that contract. +export default { + async fetch(request, env, ctx) { + const options: CloudflareOptions = { + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1, + enableOpenTelemetrySetup: true, + }; + + return wrapRequestHandler({ options, request, context: ctx }, async () => { + const tracer = trace.getTracer('sveltekit'); + + await tracer.startActiveSpan('sveltekit.handle.root', async handleSpan => { + handleSpan.end(); + }); + + return new Response('ok'); + }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts new file mode 100644 index 000000000000..596ae6c6ef6d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts @@ -0,0 +1,19 @@ +import type { Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('does not capture spans emitted through @opentelemetry/api inside wrapRequestHandler from the /request subpath', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /'); + expect(event.contexts?.trace?.op).toBe('http.server'); + expect(event.spans).toEqual([]); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/wrangler.jsonc new file mode 100644 index 000000000000..d186c1175b0d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "cloudflare-opentelemetry-tracer-subpath-request-handler", + "main": "index.ts", + "compatibility_date": "2025-06-17", +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index ca7e382351e3..d0d3a603b9ac 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -116,7 +116,7 @@ export { instrumentAgentWithSentry, instrumentDurableObjectWithSentry } from './ export { sentryPagesPlugin } from './pages-plugin'; export { CloudflareClient } from './client'; -export { getDefaultIntegrations } from './sdk'; +export { _INTERNAL_wrapRequestHandler, getDefaultIntegrations } from './sdk'; export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 582997c394ae..4f4148396040 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core'; import { getBaseDefaultIntegrations, initWithDefaultIntegrations } from './baseSdk'; import type { CloudflareClient, CloudflareOptions } from './client'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; +import { type RequestHandlerWrapperOptions, wrapRequestHandlerWithInit } from './wrapRequestHandlerWithInit'; // Test-only helper, re-exported here so tests can reset the global client cache. export { _clearGlobalClientCache } from './clientCache'; @@ -29,3 +30,20 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { return initWithDefaultIntegrations(options, getDefaultIntegrations); } + +/** + * `wrapRequestHandler` backed by `init`, so the request gets the full default integrations and + * honors `enableOpenTelemetrySetup`. The `@sentry/cloudflare/request` variant deliberately skips + * both to stay usable without `nodejs_compat`. + * + * For framework SDKs building on the main entry point, e.g. SvelteKit, whose OpenTelemetry spans + * need the tracer provider. + * + * @internal + */ +export function _INTERNAL_wrapRequestHandler( + wrapperOptions: RequestHandlerWrapperOptions, + handler: (...args: unknown[]) => Response | Promise, +): Promise { + return wrapRequestHandlerWithInit(wrapperOptions, handler, init); +} diff --git a/packages/cloudflare/test/opentelemetry.test.ts b/packages/cloudflare/test/opentelemetry.test.ts index 574e935ff5a7..afc4575fa343 100644 --- a/packages/cloudflare/test/opentelemetry.test.ts +++ b/packages/cloudflare/test/opentelemetry.test.ts @@ -1,8 +1,11 @@ import { trace } from '@opentelemetry/api'; import type { TransactionEvent } from '@sentry/core'; import { getActiveSpan, spanToJSON, startSpan } from '@sentry/core'; -import { beforeEach, describe, expect, test } from 'vitest'; -import { init } from '../src/sdk'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { CloudflareOptions } from '../src/client'; +import { wrapRequestHandler } from '../src/request'; +import { _INTERNAL_wrapRequestHandler, init } from '../src/sdk'; import { resetSdk } from './testUtils'; describe('opentelemetry compatibility', () => { @@ -305,3 +308,66 @@ describe('opentelemetry compatibility', () => { }); }); }); + +describe('request wrappers', () => { + beforeEach(() => { + resetSdk(); + setAsyncLocalStorageAsyncContextStrategy(); + }); + + async function runRequest(wrap: typeof _INTERNAL_wrapRequestHandler): Promise { + const transactionEvents: TransactionEvent[] = []; + const waits: Promise[] = []; + const context = { waitUntil: vi.fn(promise => waits.push(promise)), passThroughOnException: vi.fn() }; + + const options: CloudflareOptions = { + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + cacheClient: false, + enableOpenTelemetrySetup: true, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }; + + const response = await wrap({ options, request: new Request('https://example.com/kit'), context }, () => { + trace.getTracer('kit').startActiveSpan('sveltekit.resolve', span => { + span.end(); + }); + return new Response('ok'); + }); + + // The request span ends once the response body is consumed + await response.text(); + await Promise.all(waits); + + return transactionEvents; + } + + test('_INTERNAL_wrapRequestHandler nests spans emitted via @opentelemetry/api under the request span', async () => { + const transactionEvents = await runRequest(_INTERNAL_wrapRequestHandler); + + expect(transactionEvents).toHaveLength(1); + const [transactionEvent] = transactionEvents; + + expect(transactionEvent?.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent?.spans).toEqual([ + expect.objectContaining({ + description: 'sveltekit.resolve', + parent_span_id: transactionEvent?.contexts?.trace?.span_id, + }), + ]); + }); + + test('wrapRequestHandler from the /request entry point ignores enableOpenTelemetrySetup', async () => { + const transactionEvents = await runRequest(wrapRequestHandler); + + expect(transactionEvents).toHaveLength(1); + const [transactionEvent] = transactionEvents; + + expect(transactionEvent?.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent?.spans).toEqual([]); + }); +}); diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 5cf5204eaf72..4d3de90f750f 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -1,9 +1,9 @@ import { + _INTERNAL_wrapRequestHandler as wrapRequestHandler, type CloudflareOptions, getDefaultIntegrations as getDefaultCloudflareIntegrations, setAsyncLocalStorageAsyncContextStrategy, } from '@sentry/cloudflare'; -import { wrapRequestHandler } from '@sentry/cloudflare/request'; import { addNonEnumerableProperty } from '@sentry/core'; import type { Handle } from '@sveltejs/kit'; import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration'; @@ -23,9 +23,10 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { rewriteFramesIntegration(), svelteKitSpansIntegration(), ], - // SvelteKit emits its own OpenTelemetry spans (Kit tracing), so — like the Node SvelteKit SDK — it + // SvelteKit emits its own OpenTelemetry spans (Kit tracing), so, like the Node SvelteKit SDK, it // defaults to registering the tracer provider instead of inheriting Cloudflare's no-provider default. - // A user-provided value still overrides this via `...options`. + // Only the `init`-backed wrapper from the main entry point honors this; `@sentry/cloudflare/request` + // ignores it. A user-provided value still overrides this via `...options`. enableOpenTelemetrySetup: true, ...options, }; diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts index 3391bbee0041..dc7a43c61e41 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -1,13 +1,12 @@ import * as SentryCloudflare from '@sentry/cloudflare'; -import { wrapRequestHandler } from '@sentry/cloudflare/request'; -import type * as SentryCloudflareRequest from '@sentry/cloudflare/request'; +import { _INTERNAL_wrapRequestHandler as wrapRequestHandler } from '@sentry/cloudflare'; import type { Carrier, GLOBAL_OBJ } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { initCloudflareSentryHandle } from '../../src/worker'; -vi.mock('@sentry/cloudflare/request', async importOriginal => { - const actual = await importOriginal(); - return { ...actual, wrapRequestHandler: vi.fn(actual.wrapRequestHandler) }; +vi.mock('@sentry/cloudflare', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, _INTERNAL_wrapRequestHandler: vi.fn(actual._INTERNAL_wrapRequestHandler) }; }); const globalWithSentry = globalThis as typeof GLOBAL_OBJ & Carrier;