Skip to content

Commit 406ce22

Browse files
sergicalclaude
andauthored
fix(deno): Clear pre-existing OTel global before registering TracerProvider (#19723)
## Summary - Calls `trace.disable()` before `trace.setGlobalTracerProvider()` in `@sentry/deno`'s OTel tracer setup - This fixes silent registration failure when Supabase Edge Runtime (or Deno's native OTel) pre-registers a `TracerProvider` on the `@opentelemetry/api` global (`Symbol.for('opentelemetry.js.api.1')`) - Without this fix, **OTel-instrumented spans** (e.g. `gen_ai.*` from AI SDK, or any library using `@opentelemetry/api`) never reach Sentry because Sentry's `TracerProvider` fails to register as the global. Sentry's own `startSpan()` API is unaffected since it bypasses the OTel global. ## Context Supabase Edge Runtime (Deno 2.1.4+) registers its own `TracerProvider` before user code runs. The OTel API's `trace.setGlobalTracerProvider()` is a no-op if a provider is already registered (it only logs a diag warning), so Sentry's tracer silently gets ignored. **What works without the fix:** `Sentry.startSpan()` — goes through Sentry's internal pipeline, not the OTel global. **What breaks without the fix:** Any spans created via `@opentelemetry/api` (AI SDK's `gen_ai.*` spans, HTTP instrumentations, etc.) — these hit the pre-existing Supabase provider instead of Sentry's. Calling `trace.disable()` clears the global, allowing `trace.setGlobalTracerProvider()` to succeed. This matches the pattern already used in `cleanupOtel()` in the test file and is safe because: 1. It only runs once during `Sentry.init()` 2. Any pre-existing provider is immediately replaced by Sentry's 3. It's gated behind `skipOpenTelemetrySetup` so users with custom OTel setups can opt out 4. The Cloudflare package was investigated and doesn't have the same issue ## Test plan - [x] Updated `should override pre-existing OTel provider with Sentry provider` unit test — simulates a pre-existing provider and verifies Sentry overrides it - [x] Updated `should override native Deno OpenTelemetry when enabled` unit test — verifies Sentry captures spans even when `OTEL_DENO=true` - [x] **E2E test app** (`dev-packages/e2e-tests/test-applications/deno/`) — Deno server with pre-existing OTel provider, 5 tests: - Error capture (`Sentry.captureException`) - `Sentry.startSpan` transaction - OTel `tracer.startSpan` despite pre-existing provider (core regression test) - OTel `tracer.startActiveSpan` (AI SDK pattern) - Sentry + OTel interop (OTel child inside Sentry parent) - [x] Verified manually with Supabase Edge Function + AI SDK: `Sentry.startSpan()` spans appeared in Sentry both before and after the fix, but `gen_ai.*` OTel spans only appeared after the fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Closes #19724 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 41fc707 commit 406ce22

File tree

11 files changed

+280
-34
lines changed

11 files changed

+280
-34
lines changed

.github/workflows/build.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,11 @@ jobs:
969969
with:
970970
use-installer: true
971971
token: ${{ secrets.GITHUB_TOKEN }}
972+
- name: Set up Deno
973+
if: matrix.test-application == 'deno'
974+
uses: denoland/setup-deno@v2.0.3
975+
with:
976+
deno-version: v2.1.5
972977
- name: Restore caches
973978
uses: ./.github/actions/restore-cache
974979
with:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"imports": {
3+
"@sentry/deno": "npm:@sentry/deno",
4+
"@sentry/core": "npm:@sentry/core",
5+
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0"
6+
},
7+
"nodeModulesDir": "manual"
8+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "deno-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "deno run --allow-net --allow-env --allow-read src/app.ts",
7+
"test": "playwright test",
8+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
9+
"test:build": "pnpm install",
10+
"test:assert": "pnpm test"
11+
},
12+
"dependencies": {
13+
"@sentry/deno": "latest || *",
14+
"@opentelemetry/api": "^1.9.0"
15+
},
16+
"devDependencies": {
17+
"@playwright/test": "~1.56.0",
18+
"@sentry-internal/test-utils": "link:../../../test-utils"
19+
},
20+
"volta": {
21+
"extends": "../../package.json"
22+
}
23+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
port: 3030,
6+
});
7+
8+
export default config;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { trace } from '@opentelemetry/api';
2+
3+
// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers
4+
// before user code runs). Without trace.disable() in Sentry's setup, this would
5+
// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans.
6+
const fakeProvider = {
7+
getTracer: () => ({
8+
startSpan: () => ({ end: () => {}, setAttributes: () => {} }),
9+
startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }),
10+
}),
11+
};
12+
trace.setGlobalTracerProvider(fakeProvider as any);
13+
14+
// Sentry.init() must call trace.disable() to clear the fake provider above
15+
import * as Sentry from '@sentry/deno';
16+
17+
Sentry.init({
18+
environment: 'qa',
19+
dsn: Deno.env.get('E2E_TEST_DSN'),
20+
debug: !!Deno.env.get('DEBUG'),
21+
tunnel: 'http://localhost:3031/',
22+
tracesSampleRate: 1,
23+
});
24+
25+
const port = 3030;
26+
27+
Deno.serve({ port }, (req: Request) => {
28+
const url = new URL(req.url);
29+
30+
if (url.pathname === '/test-success') {
31+
return new Response(JSON.stringify({ version: 'v1' }), {
32+
headers: { 'Content-Type': 'application/json' },
33+
});
34+
}
35+
36+
if (url.pathname === '/test-error') {
37+
const exceptionId = Sentry.captureException(new Error('This is an error'));
38+
return new Response(JSON.stringify({ exceptionId }), {
39+
headers: { 'Content-Type': 'application/json' },
40+
});
41+
}
42+
43+
// Test Sentry.startSpan — uses Sentry's internal pipeline
44+
if (url.pathname === '/test-sentry-span') {
45+
Sentry.startSpan({ name: 'test-sentry-span' }, () => {
46+
// noop
47+
});
48+
return new Response(JSON.stringify({ status: 'ok' }), {
49+
headers: { 'Content-Type': 'application/json' },
50+
});
51+
}
52+
53+
// Test OTel tracer.startSpan — goes through the global TracerProvider
54+
if (url.pathname === '/test-otel-span') {
55+
const tracer = trace.getTracer('test-tracer');
56+
const span = tracer.startSpan('test-otel-span');
57+
span.end();
58+
return new Response(JSON.stringify({ status: 'ok' }), {
59+
headers: { 'Content-Type': 'application/json' },
60+
});
61+
}
62+
63+
// Test OTel tracer.startActiveSpan — what AI SDK and most instrumentations use
64+
if (url.pathname === '/test-otel-active-span') {
65+
const tracer = trace.getTracer('test-tracer');
66+
tracer.startActiveSpan('test-otel-active-span', span => {
67+
span.setAttributes({ 'test.active': true });
68+
span.end();
69+
});
70+
return new Response(JSON.stringify({ status: 'ok' }), {
71+
headers: { 'Content-Type': 'application/json' },
72+
});
73+
}
74+
75+
// Test interop: OTel span inside a Sentry span
76+
if (url.pathname === '/test-interop') {
77+
Sentry.startSpan({ name: 'sentry-parent' }, () => {
78+
const tracer = trace.getTracer('test-tracer');
79+
const span = tracer.startSpan('otel-child');
80+
span.end();
81+
});
82+
return new Response(JSON.stringify({ status: 'ok' }), {
83+
headers: { 'Content-Type': 'application/json' },
84+
});
85+
}
86+
87+
return new Response('Not found', { status: 404 });
88+
});
89+
90+
console.log(`Deno test app listening on port ${port}`);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'deno',
6+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test('Sends error event', async ({ baseURL }) => {
5+
const errorEventPromise = waitForError('deno', event => {
6+
return !event.type && event.exception?.values?.[0]?.value === 'This is an error';
7+
});
8+
9+
await fetch(`${baseURL}/test-error`);
10+
11+
const errorEvent = await errorEventPromise;
12+
13+
expect(errorEvent.exception?.values).toHaveLength(1);
14+
expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an error');
15+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Sends transaction with Sentry.startSpan', async ({ baseURL }) => {
5+
const transactionPromise = waitForTransaction('deno', event => {
6+
return event?.spans?.some(span => span.description === 'test-sentry-span') ?? false;
7+
});
8+
9+
await fetch(`${baseURL}/test-sentry-span`);
10+
11+
const transaction = await transactionPromise;
12+
13+
expect(transaction.spans).toEqual(
14+
expect.arrayContaining([
15+
expect.objectContaining({
16+
description: 'test-sentry-span',
17+
origin: 'manual',
18+
}),
19+
]),
20+
);
21+
});
22+
23+
test('Sends transaction with OTel tracer.startSpan despite pre-existing provider', async ({ baseURL }) => {
24+
const transactionPromise = waitForTransaction('deno', event => {
25+
return event?.spans?.some(span => span.description === 'test-otel-span') ?? false;
26+
});
27+
28+
await fetch(`${baseURL}/test-otel-span`);
29+
30+
const transaction = await transactionPromise;
31+
32+
expect(transaction.spans).toEqual(
33+
expect.arrayContaining([
34+
expect.objectContaining({
35+
description: 'test-otel-span',
36+
op: 'otel.span',
37+
origin: 'manual',
38+
}),
39+
]),
40+
);
41+
});
42+
43+
test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => {
44+
const transactionPromise = waitForTransaction('deno', event => {
45+
return event?.spans?.some(span => span.description === 'test-otel-active-span') ?? false;
46+
});
47+
48+
await fetch(`${baseURL}/test-otel-active-span`);
49+
50+
const transaction = await transactionPromise;
51+
52+
expect(transaction.spans).toEqual(
53+
expect.arrayContaining([
54+
expect.objectContaining({
55+
description: 'test-otel-active-span',
56+
op: 'otel.span',
57+
origin: 'manual',
58+
}),
59+
]),
60+
);
61+
});
62+
63+
test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => {
64+
const transactionPromise = waitForTransaction('deno', event => {
65+
return event?.spans?.some(span => span.description === 'sentry-parent') ?? false;
66+
});
67+
68+
await fetch(`${baseURL}/test-interop`);
69+
70+
const transaction = await transactionPromise;
71+
72+
expect(transaction.spans).toEqual(
73+
expect.arrayContaining([
74+
expect.objectContaining({
75+
description: 'sentry-parent',
76+
origin: 'manual',
77+
}),
78+
expect.objectContaining({
79+
description: 'otel-child',
80+
op: 'otel.span',
81+
origin: 'manual',
82+
}),
83+
]),
84+
);
85+
86+
// Verify the OTel span is a child of the Sentry span
87+
const sentrySpan = transaction.spans!.find((s: any) => s.description === 'sentry-parent');
88+
const otelSpan = transaction.spans!.find((s: any) => s.description === 'otel-child');
89+
expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id);
90+
});

packages/deno/src/opentelemetry/tracer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
* This is not perfect but handles easy/common use cases.
1313
*/
1414
export function setupOpenTelemetryTracer(): void {
15+
// Clear any pre-existing OTel global registration (e.g. from Supabase Edge Runtime
16+
// or Deno's built-in OTel) so Sentry's TracerProvider gets registered successfully.
17+
trace.disable();
1518
trace.setGlobalTracerProvider(new SentryDenoTraceProvider());
1619
}
1720

0 commit comments

Comments
 (0)