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
Original file line number Diff line number Diff line change
@@ -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<Env>;
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "cloudflare-opentelemetry-tracer-internal-request-handler",
"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,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<Env>;
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "cloudflare-opentelemetry-tracer-subpath-request-handler",
"main": "index.ts",
"compatibility_date": "2025-06-17",
}
2 changes: 1 addition & 1 deletion packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 18 additions & 0 deletions packages/cloudflare/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Response>,
): Promise<Response> {
return wrapRequestHandlerWithInit(wrapperOptions, handler, init);
}
70 changes: 68 additions & 2 deletions packages/cloudflare/test/opentelemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -305,3 +308,66 @@ describe('opentelemetry compatibility', () => {
});
});
});

describe('request wrappers', () => {
beforeEach(() => {
resetSdk();
setAsyncLocalStorageAsyncContextStrategy();
});

async function runRequest(wrap: typeof _INTERNAL_wrapRequestHandler): Promise<TransactionEvent[]> {
const transactionEvents: TransactionEvent[] = [];
const waits: Promise<unknown>[] = [];
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([]);
});
});
7 changes: 4 additions & 3 deletions packages/sveltekit/src/worker/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,9 +23,10 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
rewriteFramesIntegration(),
svelteKitSpansIntegration(),
],
// SvelteKit emits its own OpenTelemetry spans (Kit tracing), solike 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,
};
Expand Down
9 changes: 4 additions & 5 deletions packages/sveltekit/test/worker/cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SentryCloudflareRequest>();
return { ...actual, wrapRequestHandler: vi.fn(actual.wrapRequestHandler) };
vi.mock('@sentry/cloudflare', async importOriginal => {
const actual = await importOriginal<typeof SentryCloudflare>();
return { ...actual, _INTERNAL_wrapRequestHandler: vi.fn(actual._INTERNAL_wrapRequestHandler) };
});

const globalWithSentry = globalThis as typeof GLOBAL_OBJ & Carrier;
Expand Down
Loading