From 670aa946735b419f8c72e4e31ded94c0909b5e74 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 24 Jun 2025 10:45:20 +0200 Subject: [PATCH 1/3] feat(cloudflare): Allow interop with OpenTelemetry emitted spans --- packages/cloudflare/package.json | 3 +- .../cloudflare/src/opentelemetry/tracer.ts | 69 +++++++++++++++++++ packages/cloudflare/src/sdk.ts | 3 + 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/cloudflare/src/opentelemetry/tracer.ts diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index e23ffd9383b4..8142b4914ef1 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -49,7 +49,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.31.0" + "@sentry/core": "9.31.0", + "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/cloudflare/src/opentelemetry/tracer.ts b/packages/cloudflare/src/opentelemetry/tracer.ts new file mode 100644 index 000000000000..7ea2240ad6c1 --- /dev/null +++ b/packages/cloudflare/src/opentelemetry/tracer.ts @@ -0,0 +1,69 @@ +import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import { startInactiveSpan, startSpan } from '@sentry/core'; + +/** + * 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 = 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({ name, ...options }); + } + + /** + * NOTE: This does not handle `context` being passed in. It will always put spans on the current scope. + */ + public startActiveSpan unknown>(name: string, fn: F): ReturnType; + public startActiveSpan unknown>(name: string, options: SpanOptions, fn: F): ReturnType; + public startActiveSpan unknown>( + name: string, + options: SpanOptions, + context: Context, + fn: F, + ): ReturnType; + public startActiveSpan unknown>( + name: string, + options: unknown, + context?: unknown, + fn?: F, + ): ReturnType { + const opts = typeof options === 'object' && options !== null ? options : {}; + + const spanOpts = { + name, + ...opts, + }; + + const callback = ( + typeof options === 'function' + ? options + : typeof context === 'function' + ? context + : typeof fn === 'function' + ? fn + : // eslint-disable-next-line @typescript-eslint/no-empty-function + () => {} + ) as F; + + return startSpan(spanOpts, callback) as ReturnType; + } +} diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 90ef3c0bedf9..770f5cb127eb 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -13,6 +13,7 @@ import { import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; import { fetchIntegration } from './integrations/fetch'; +import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; @@ -50,5 +51,7 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { transport: options.transport || makeCloudflareTransport, }; + setupOpenTelemetryTracer(); + return initAndBind(CloudflareClient, clientOptions) as CloudflareClient; } From 729f388f4dbcaa642a37bd531f40d3cce5c1909b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 24 Jun 2025 10:55:54 +0200 Subject: [PATCH 2/3] fix --- packages/cloudflare/src/opentelemetry/tracer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/opentelemetry/tracer.ts b/packages/cloudflare/src/opentelemetry/tracer.ts index 7ea2240ad6c1..be561d7fe7ea 100644 --- a/packages/cloudflare/src/opentelemetry/tracer.ts +++ b/packages/cloudflare/src/opentelemetry/tracer.ts @@ -1,6 +1,6 @@ import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; -import { startInactiveSpan, startSpan } from '@sentry/core'; +import { startInactiveSpan, startSpanManual } from '@sentry/core'; /** * Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans. @@ -64,6 +64,7 @@ class SentryCloudflareTracer implements Tracer { () => {} ) as F; - return startSpan(spanOpts, callback) as ReturnType; + // In OTEL the semantic matches `startSpanManual` because spans are not auto-ended + return startSpanManual(spanOpts, callback) as ReturnType; } } From 29f9e8eaeb5dc42305e5a11513420c684053ba0a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 25 Jun 2025 12:05:57 +0200 Subject: [PATCH 3/3] add compatibility & tests --- packages/cloudflare/src/client.ts | 13 ++ .../cloudflare/src/opentelemetry/tracer.ts | 15 +- packages/cloudflare/src/sdk.ts | 11 +- .../cloudflare/test/opentelemetry.test.ts | 145 ++++++++++++++++++ packages/cloudflare/test/sdk.test.ts | 7 +- packages/cloudflare/test/testUtils.ts | 21 +++ 6 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 packages/cloudflare/test/opentelemetry.test.ts create mode 100644 packages/cloudflare/test/testUtils.ts diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 9b4f18086658..07dc285e6b15 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -36,6 +36,19 @@ interface BaseCloudflareOptions { * @default true */ enableDedupe?: boolean; + + /** + * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility + * via a custom trace provider. + * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. + * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. + * This should be good enough for many, but not all integrations. + * + * If you want to opt-out of setting up the OpenTelemetry compatibility tracer, set this to `true`. + * + * @default false + */ + skipOpenTelemetrySetup?: boolean; } /** diff --git a/packages/cloudflare/src/opentelemetry/tracer.ts b/packages/cloudflare/src/opentelemetry/tracer.ts index be561d7fe7ea..94dc917c5070 100644 --- a/packages/cloudflare/src/opentelemetry/tracer.ts +++ b/packages/cloudflare/src/opentelemetry/tracer.ts @@ -26,7 +26,14 @@ class SentryCloudflareTraceProvider implements TracerProvider { class SentryCloudflareTracer implements Tracer { public startSpan(name: string, options?: SpanOptions): Span { - return startInactiveSpan({ name, ...options }); + return startInactiveSpan({ + name, + ...options, + attributes: { + ...options?.attributes, + 'sentry.cloudflare_tracer': true, + }, + }); } /** @@ -46,11 +53,15 @@ class SentryCloudflareTracer implements Tracer { context?: unknown, fn?: F, ): ReturnType { - const opts = typeof options === 'object' && options !== null ? options : {}; + const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions; const spanOpts = { name, ...opts, + attributes: { + ...opts.attributes, + 'sentry.cloudflare_tracer': true, + }, }; const callback = ( diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 770f5cb127eb..96b3152e6480 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -51,7 +51,16 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { transport: options.transport || makeCloudflareTransport, }; - setupOpenTelemetryTracer(); + /** + * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility + * via a custom trace provider. + * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. + * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. + * This should be good enough for many, but not all integrations. + */ + if (!options.skipOpenTelemetrySetup) { + setupOpenTelemetryTracer(); + } return initAndBind(CloudflareClient, clientOptions) as CloudflareClient; } diff --git a/packages/cloudflare/test/opentelemetry.test.ts b/packages/cloudflare/test/opentelemetry.test.ts new file mode 100644 index 000000000000..f918afff90cc --- /dev/null +++ b/packages/cloudflare/test/opentelemetry.test.ts @@ -0,0 +1,145 @@ +import { trace } from '@opentelemetry/api'; +import type { TransactionEvent } from '@sentry/core'; +import { startSpan } from '@sentry/core'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { init } from '../src/sdk'; +import { resetSdk } from './testUtils'; + +describe('opentelemetry compatibility', () => { + beforeEach(() => { + resetSdk(); + }); + + test('should not capture spans emitted via @opentelemetry/api when skipOpenTelemetrySetup is true', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); + span.end(); + + await client!.flush(); + + tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => { + const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } }); + span.end(); + span2.end(); + }); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(0); + }); + + test('should capture spans emitted via @opentelemetry/api', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); + span.end(); + + await client!.flush(); + + tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => { + const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } }); + span.end(); + span2.end(); + }); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(2); + const [transactionEvent, transactionEvent2] = transactionEvents; + + expect(transactionEvent?.spans?.length).toBe(0); + expect(transactionEvent?.transaction).toBe('test'); + expect(transactionEvent?.contexts?.trace?.data).toEqual({ + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + }); + + expect(transactionEvent2?.spans?.length).toBe(1); + expect(transactionEvent2?.transaction).toBe('test 2'); + expect(transactionEvent2?.contexts?.trace?.data).toEqual({ + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'test.attribute': 'test', + }); + + expect(transactionEvent2?.spans).toEqual([ + expect.objectContaining({ + description: 'test 3', + data: { + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + 'test.attribute': 'test2', + }, + }), + ]); + }); + + test('opentelemetry spans should interop with Sentry spans', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + + startSpan({ name: 'sentry span' }, () => { + const span = tracer.startSpan('otel span'); + span.end(); + }); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(1); + const [transactionEvent] = transactionEvents; + + expect(transactionEvent?.spans?.length).toBe(1); + expect(transactionEvent?.transaction).toBe('sentry span'); + expect(transactionEvent?.contexts?.trace?.data).toEqual({ + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + }); + + expect(transactionEvent?.spans).toEqual([ + expect.objectContaining({ + description: 'otel span', + data: { + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + }, + }), + ]); + }); +}); diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 5c876812b035..2f4ec7844559 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,9 +1,14 @@ import * as SentryCore from '@sentry/core'; -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; import { init } from '../src/sdk'; +import { resetSdk } from './testUtils'; describe('init', () => { + beforeEach(() => { + resetSdk(); + }); + test('should call initAndBind with the correct options', () => { const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); const client = init({}); diff --git a/packages/cloudflare/test/testUtils.ts b/packages/cloudflare/test/testUtils.ts new file mode 100644 index 000000000000..8dcd3d43a4d9 --- /dev/null +++ b/packages/cloudflare/test/testUtils.ts @@ -0,0 +1,21 @@ +import { context, propagation, trace } from '@opentelemetry/api'; +import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +function cleanupOtel(): void { + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); +} + +export function resetSdk(): void { + resetGlobals(); + cleanupOtel(); +}