Skip to content

Commit 327594c

Browse files
authored
feat(cloudflare): Allow interop with OpenTelemetry emitted spans (#16714)
While we eventually want to move the cloudflare SDK over to use OTEL fully under the hood, this PR is an attempt for an intermediate solution to allow us to still get access to e.g. the vercelAi integration, which emits spans via `@opentelemetry/core`. For this, we register a custom trace provider in the cloudflare SDK which then just calls our own `startSpan` APIs. This should translate spans to Sentry spans. The only downside is that it does not handle a `context` being passed in for spans, so this will _not_ work for all integrations and cases etc. But it should work for simple cases like the `ai` package using `trace.startActiveSpan`. TODO: Test this, verify this makes sense, ...
1 parent 9659275 commit 327594c

File tree

7 files changed

+280
-2
lines changed

7 files changed

+280
-2
lines changed

packages/cloudflare/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"access": "public"
5050
},
5151
"dependencies": {
52-
"@sentry/core": "9.31.0"
52+
"@sentry/core": "9.31.0",
53+
"@opentelemetry/api": "^1.9.0"
5354
},
5455
"peerDependencies": {
5556
"@cloudflare/workers-types": "^4.x"

packages/cloudflare/src/client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ interface BaseCloudflareOptions {
3636
* @default true
3737
*/
3838
enableDedupe?: boolean;
39+
40+
/**
41+
* The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
42+
* via a custom trace provider.
43+
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
44+
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
45+
* This should be good enough for many, but not all integrations.
46+
*
47+
* If you want to opt-out of setting up the OpenTelemetry compatibility tracer, set this to `true`.
48+
*
49+
* @default false
50+
*/
51+
skipOpenTelemetrySetup?: boolean;
3952
}
4053

4154
/**
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api';
2+
import { trace } from '@opentelemetry/api';
3+
import { startInactiveSpan, startSpanManual } from '@sentry/core';
4+
5+
/**
6+
* Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans.
7+
* This is not perfect but handles easy/common use cases.
8+
*/
9+
export function setupOpenTelemetryTracer(): void {
10+
trace.setGlobalTracerProvider(new SentryCloudflareTraceProvider());
11+
}
12+
13+
class SentryCloudflareTraceProvider implements TracerProvider {
14+
private readonly _tracers: Map<string, Tracer> = new Map();
15+
16+
public getTracer(name: string, version?: string, options?: { schemaUrl?: string }): Tracer {
17+
const key = `${name}@${version || ''}:${options?.schemaUrl || ''}`;
18+
if (!this._tracers.has(key)) {
19+
this._tracers.set(key, new SentryCloudflareTracer());
20+
}
21+
22+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
23+
return this._tracers.get(key)!;
24+
}
25+
}
26+
27+
class SentryCloudflareTracer implements Tracer {
28+
public startSpan(name: string, options?: SpanOptions): Span {
29+
return startInactiveSpan({
30+
name,
31+
...options,
32+
attributes: {
33+
...options?.attributes,
34+
'sentry.cloudflare_tracer': true,
35+
},
36+
});
37+
}
38+
39+
/**
40+
* NOTE: This does not handle `context` being passed in. It will always put spans on the current scope.
41+
*/
42+
public startActiveSpan<F extends (span: Span) => unknown>(name: string, fn: F): ReturnType<F>;
43+
public startActiveSpan<F extends (span: Span) => unknown>(name: string, options: SpanOptions, fn: F): ReturnType<F>;
44+
public startActiveSpan<F extends (span: Span) => unknown>(
45+
name: string,
46+
options: SpanOptions,
47+
context: Context,
48+
fn: F,
49+
): ReturnType<F>;
50+
public startActiveSpan<F extends (span: Span) => unknown>(
51+
name: string,
52+
options: unknown,
53+
context?: unknown,
54+
fn?: F,
55+
): ReturnType<F> {
56+
const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions;
57+
58+
const spanOpts = {
59+
name,
60+
...opts,
61+
attributes: {
62+
...opts.attributes,
63+
'sentry.cloudflare_tracer': true,
64+
},
65+
};
66+
67+
const callback = (
68+
typeof options === 'function'
69+
? options
70+
: typeof context === 'function'
71+
? context
72+
: typeof fn === 'function'
73+
? fn
74+
: // eslint-disable-next-line @typescript-eslint/no-empty-function
75+
() => {}
76+
) as F;
77+
78+
// In OTEL the semantic matches `startSpanManual` because spans are not auto-ended
79+
return startSpanManual(spanOpts, callback) as ReturnType<F>;
80+
}
81+
}

packages/cloudflare/src/sdk.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import type { CloudflareClientOptions, CloudflareOptions } from './client';
1414
import { CloudflareClient } from './client';
1515
import { fetchIntegration } from './integrations/fetch';
16+
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
1617
import { makeCloudflareTransport } from './transport';
1718
import { defaultStackParser } from './vendor/stacktrace';
1819

@@ -50,5 +51,16 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined {
5051
transport: options.transport || makeCloudflareTransport,
5152
};
5253

54+
/**
55+
* The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
56+
* via a custom trace provider.
57+
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
58+
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
59+
* This should be good enough for many, but not all integrations.
60+
*/
61+
if (!options.skipOpenTelemetrySetup) {
62+
setupOpenTelemetryTracer();
63+
}
64+
5365
return initAndBind(CloudflareClient, clientOptions) as CloudflareClient;
5466
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { trace } from '@opentelemetry/api';
2+
import type { TransactionEvent } from '@sentry/core';
3+
import { startSpan } from '@sentry/core';
4+
import { beforeEach, describe, expect, test } from 'vitest';
5+
import { init } from '../src/sdk';
6+
import { resetSdk } from './testUtils';
7+
8+
describe('opentelemetry compatibility', () => {
9+
beforeEach(() => {
10+
resetSdk();
11+
});
12+
13+
test('should not capture spans emitted via @opentelemetry/api when skipOpenTelemetrySetup is true', async () => {
14+
const transactionEvents: TransactionEvent[] = [];
15+
16+
const client = init({
17+
dsn: 'https://username@domain/123',
18+
tracesSampleRate: 1,
19+
skipOpenTelemetrySetup: true,
20+
beforeSendTransaction: event => {
21+
transactionEvents.push(event);
22+
return null;
23+
},
24+
});
25+
26+
const tracer = trace.getTracer('test');
27+
const span = tracer.startSpan('test');
28+
span.end();
29+
30+
await client!.flush();
31+
32+
tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => {
33+
const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } });
34+
span.end();
35+
span2.end();
36+
});
37+
38+
await client!.flush();
39+
40+
expect(transactionEvents).toHaveLength(0);
41+
});
42+
43+
test('should capture spans emitted via @opentelemetry/api', async () => {
44+
const transactionEvents: TransactionEvent[] = [];
45+
46+
const client = init({
47+
dsn: 'https://username@domain/123',
48+
tracesSampleRate: 1,
49+
beforeSendTransaction: event => {
50+
transactionEvents.push(event);
51+
return null;
52+
},
53+
});
54+
55+
const tracer = trace.getTracer('test');
56+
const span = tracer.startSpan('test');
57+
span.end();
58+
59+
await client!.flush();
60+
61+
tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => {
62+
const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } });
63+
span.end();
64+
span2.end();
65+
});
66+
67+
await client!.flush();
68+
69+
expect(transactionEvents).toHaveLength(2);
70+
const [transactionEvent, transactionEvent2] = transactionEvents;
71+
72+
expect(transactionEvent?.spans?.length).toBe(0);
73+
expect(transactionEvent?.transaction).toBe('test');
74+
expect(transactionEvent?.contexts?.trace?.data).toEqual({
75+
'sentry.cloudflare_tracer': true,
76+
'sentry.origin': 'manual',
77+
'sentry.sample_rate': 1,
78+
'sentry.source': 'custom',
79+
});
80+
81+
expect(transactionEvent2?.spans?.length).toBe(1);
82+
expect(transactionEvent2?.transaction).toBe('test 2');
83+
expect(transactionEvent2?.contexts?.trace?.data).toEqual({
84+
'sentry.cloudflare_tracer': true,
85+
'sentry.origin': 'manual',
86+
'sentry.sample_rate': 1,
87+
'sentry.source': 'custom',
88+
'test.attribute': 'test',
89+
});
90+
91+
expect(transactionEvent2?.spans).toEqual([
92+
expect.objectContaining({
93+
description: 'test 3',
94+
data: {
95+
'sentry.cloudflare_tracer': true,
96+
'sentry.origin': 'manual',
97+
'test.attribute': 'test2',
98+
},
99+
}),
100+
]);
101+
});
102+
103+
test('opentelemetry spans should interop with Sentry spans', async () => {
104+
const transactionEvents: TransactionEvent[] = [];
105+
106+
const client = init({
107+
dsn: 'https://username@domain/123',
108+
tracesSampleRate: 1,
109+
beforeSendTransaction: event => {
110+
transactionEvents.push(event);
111+
return null;
112+
},
113+
});
114+
115+
const tracer = trace.getTracer('test');
116+
117+
startSpan({ name: 'sentry span' }, () => {
118+
const span = tracer.startSpan('otel span');
119+
span.end();
120+
});
121+
122+
await client!.flush();
123+
124+
expect(transactionEvents).toHaveLength(1);
125+
const [transactionEvent] = transactionEvents;
126+
127+
expect(transactionEvent?.spans?.length).toBe(1);
128+
expect(transactionEvent?.transaction).toBe('sentry span');
129+
expect(transactionEvent?.contexts?.trace?.data).toEqual({
130+
'sentry.origin': 'manual',
131+
'sentry.sample_rate': 1,
132+
'sentry.source': 'custom',
133+
});
134+
135+
expect(transactionEvent?.spans).toEqual([
136+
expect.objectContaining({
137+
description: 'otel span',
138+
data: {
139+
'sentry.cloudflare_tracer': true,
140+
'sentry.origin': 'manual',
141+
},
142+
}),
143+
]);
144+
});
145+
});

packages/cloudflare/test/sdk.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import * as SentryCore from '@sentry/core';
2-
import { describe, expect, test, vi } from 'vitest';
2+
import { beforeEach, describe, expect, test, vi } from 'vitest';
33
import { CloudflareClient } from '../src/client';
44
import { init } from '../src/sdk';
5+
import { resetSdk } from './testUtils';
56

67
describe('init', () => {
8+
beforeEach(() => {
9+
resetSdk();
10+
});
11+
712
test('should call initAndBind with the correct options', () => {
813
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind');
914
const client = init({});

packages/cloudflare/test/testUtils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { context, propagation, trace } from '@opentelemetry/api';
2+
import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
3+
4+
function resetGlobals(): void {
5+
getCurrentScope().clear();
6+
getCurrentScope().setClient(undefined);
7+
getIsolationScope().clear();
8+
getGlobalScope().clear();
9+
}
10+
11+
function cleanupOtel(): void {
12+
// Disable all globally registered APIs
13+
trace.disable();
14+
context.disable();
15+
propagation.disable();
16+
}
17+
18+
export function resetSdk(): void {
19+
resetGlobals();
20+
cleanupOtel();
21+
}

0 commit comments

Comments
 (0)