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
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,11 @@ jobs:
with:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Deno
if: matrix.test-application == 'deno'
uses: denoland/setup-deno@v2.0.3
with:
deno-version: v2.1.5
- name: Restore caches
uses: ./.github/actions/restore-cache
with:
Expand Down
2 changes: 2 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@sentry:registry=http://127.0.0.1:4873
@sentry-internal:registry=http://127.0.0.1:4873
8 changes: 8 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno/deno.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm I assume this means that using a custom .npmrc pointing it to our verdaccio host instead of NPM for @sentry packages doesn't work in Deno?

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"imports": {
"@sentry/deno": "npm:@sentry/deno",
"@sentry/core": "npm:@sentry/core",
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0"
},
"nodeModulesDir": "manual"
}
23 changes: 23 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "deno-app",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "deno run --allow-net --allow-env --allow-read src/app.ts",
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install",
"test:assert": "pnpm test"
},
"dependencies": {
"@sentry/deno": "latest || *",
"@opentelemetry/api": "^1.9.0"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: `pnpm start`,
port: 3030,
});

export default config;
90 changes: 90 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { trace } from '@opentelemetry/api';

// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers
// before user code runs). Without trace.disable() in Sentry's setup, this would
// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans.
const fakeProvider = {
getTracer: () => ({
startSpan: () => ({ end: () => {}, setAttributes: () => {} }),
startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }),
}),
};
trace.setGlobalTracerProvider(fakeProvider as any);

// Sentry.init() must call trace.disable() to clear the fake provider above
import * as Sentry from '@sentry/deno';

Sentry.init({
environment: 'qa',
dsn: Deno.env.get('E2E_TEST_DSN'),
debug: !!Deno.env.get('DEBUG'),
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1,
});

const port = 3030;

Deno.serve({ port }, (req: Request) => {
const url = new URL(req.url);

if (url.pathname === '/test-success') {
return new Response(JSON.stringify({ version: 'v1' }), {
headers: { 'Content-Type': 'application/json' },
});
}

if (url.pathname === '/test-error') {
const exceptionId = Sentry.captureException(new Error('This is an error'));
return new Response(JSON.stringify({ exceptionId }), {
headers: { 'Content-Type': 'application/json' },
});
}

// Test Sentry.startSpan — uses Sentry's internal pipeline
if (url.pathname === '/test-sentry-span') {
Sentry.startSpan({ name: 'test-sentry-span' }, () => {
// noop
});
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
});
}

// Test OTel tracer.startSpan — goes through the global TracerProvider
if (url.pathname === '/test-otel-span') {
const tracer = trace.getTracer('test-tracer');
const span = tracer.startSpan('test-otel-span');
span.end();
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
});
}

// Test OTel tracer.startActiveSpan — what AI SDK and most instrumentations use
if (url.pathname === '/test-otel-active-span') {
const tracer = trace.getTracer('test-tracer');
tracer.startActiveSpan('test-otel-active-span', span => {
span.setAttributes({ 'test.active': true });
span.end();
});
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
});
}

// Test interop: OTel span inside a Sentry span
if (url.pathname === '/test-interop') {
Sentry.startSpan({ name: 'sentry-parent' }, () => {
const tracer = trace.getTracer('test-tracer');
const span = tracer.startSpan('otel-child');
span.end();
});
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
});
}

return new Response('Not found', { status: 404 });
});

console.log(`Deno test app listening on port ${port}`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'deno',
});
15 changes: 15 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test('Sends error event', async ({ baseURL }) => {
const errorEventPromise = waitForError('deno', event => {
return !event.type && event.exception?.values?.[0]?.value === 'This is an error';
});

await fetch(`${baseURL}/test-error`);

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Sends transaction with Sentry.startSpan', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('deno', event => {
return event?.spans?.some(span => span.description === 'test-sentry-span') ?? false;
});

await fetch(`${baseURL}/test-sentry-span`);

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'test-sentry-span',
origin: 'manual',
}),
]),
);
});

test('Sends transaction with OTel tracer.startSpan despite pre-existing provider', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('deno', event => {
return event?.spans?.some(span => span.description === 'test-otel-span') ?? false;
});

await fetch(`${baseURL}/test-otel-span`);

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'test-otel-span',
op: 'otel.span',
origin: 'manual',
}),
]),
);
});

test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('deno', event => {
return event?.spans?.some(span => span.description === 'test-otel-active-span') ?? false;
});

await fetch(`${baseURL}/test-otel-active-span`);

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'test-otel-active-span',
op: 'otel.span',
origin: 'manual',
}),
]),
);
});

test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('deno', event => {
return event?.spans?.some(span => span.description === 'sentry-parent') ?? false;
});

await fetch(`${baseURL}/test-interop`);

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'sentry-parent',
origin: 'manual',
}),
expect.objectContaining({
description: 'otel-child',
op: 'otel.span',
origin: 'manual',
}),
]),
);

// Verify the OTel span is a child of the Sentry span
const sentrySpan = transaction.spans!.find((s: any) => s.description === 'sentry-parent');
const otelSpan = transaction.spans!.find((s: any) => s.description === 'otel-child');
expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id);
});
3 changes: 3 additions & 0 deletions packages/deno/src/opentelemetry/tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
* This is not perfect but handles easy/common use cases.
*/
export function setupOpenTelemetryTracer(): void {
// Clear any pre-existing OTel global registration (e.g. from Supabase Edge Runtime
// or Deno's built-in OTel) so Sentry's TracerProvider gets registered successfully.
trace.disable();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m l: I'm wondering if this backfires for people using Sentry with a custom OTel setup or deliberately with Deno's native tracing (OTLP exporter). The good news is that we don't document this setup for Deno, so I think we can just ignore it for the moment and walk back on this change if anyone complains.

Update: I just saw that we gate this function call with skipOpenTelemetrySetup, so users can opt out of it. That's good. So I guess the worst consequence here is that anyone using native tracing with Sentry might need to set this flag now. Which we can classify as a fix because that's how we intended the SDK to work anyway. Downgraded from logaf M to L

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which we can classify as a fix because that's how we intended the SDK to work anyway.

I think it's actually even more of a fix than that. If someone is using native Deno OTel tracing, then this will register Sentry as the global trace provider. If you're using Sentry in Deno, that's almost certainly what you want to see happen (and what would happen, if nothing else was getting in before our initialization).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds reasonable. Let's roll with it!

trace.setGlobalTracerProvider(new SentryDenoTraceProvider());
}

Expand Down
64 changes: 30 additions & 34 deletions packages/deno/test/opentelemetry.test.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment, no direct action required: I'm fine with these unit tests for now but to be clear these don't prove that the fix works as intended in an actual app. Long-term I'd like us to at least add one e2e app for Deno (or an integration tests setup like for Node) to more reliably verify this. This goes back to my main review comment that I don't think we fully grasped the scope of the current behavior yet. If we had such a test, we could more reliably say that at least some spans are sent.

Original file line number Diff line number Diff line change
Expand Up @@ -144,38 +144,39 @@ Deno.test('opentelemetry spans should interop with Sentry spans', async () => {
assertEquals(otelSpan?.data?.['sentry.origin'], 'manual');
});

Deno.test('should be compatible with native Deno OpenTelemetry', async () => {
Deno.test('should override pre-existing OTel provider with Sentry provider', async () => {
resetSdk();

const providerBefore = trace.getTracerProvider();
// Simulate a pre-existing OTel registration (e.g. from Supabase Edge Runtime)
const fakeProvider = { getTracer: () => ({}) };
trace.setGlobalTracerProvider(fakeProvider as any);

const transactionEvents: any[] = [];

const client = init({
dsn: 'https://username@domain/123',
tracesSampleRate: 1,
beforeSendTransaction: () => null,
beforeSendTransaction: event => {
transactionEvents.push(event);
return null;
},
}) as DenoClient;

// Sentry should have overridden the pre-existing provider via trace.disable()
const providerAfter = trace.getTracerProvider();
assertEquals(providerBefore, providerAfter);
assertNotEquals(providerAfter, fakeProvider);

// Verify Sentry's tracer actually captures spans
const tracer = trace.getTracer('compat-test');
const span = tracer.startSpan('test-span');
span.setAttributes({ 'test.compatibility': true });
span.end();

tracer.startActiveSpan('active-span', activeSpan => {
activeSpan.end();
});

const otelSpan = tracer.startSpan('post-init-span');
otelSpan.end();

startSpan({ name: 'sentry-span' }, () => {
const nestedOtelSpan = tracer.startSpan('nested-otel-span');
nestedOtelSpan.end();
});

await client.flush();

assertEquals(transactionEvents.length, 1);
assertEquals(transactionEvents[0]?.transaction, 'test-span');
assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['sentry.deno_tracer'], true);
});

// Test that name parameter takes precedence over options.name for both startSpan and startActiveSpan
Expand Down Expand Up @@ -238,42 +239,37 @@ Deno.test('name parameter should take precedence over options.name in startActiv
assertEquals(transactionEvent?.transaction, 'prisma:client:operation');
});

Deno.test('should verify native Deno OpenTelemetry works when enabled', async () => {
Deno.test('should override native Deno OpenTelemetry when enabled', async () => {
resetSdk();

// Set environment variable to enable native OTel
const originalValue = Deno.env.get('OTEL_DENO');
Deno.env.set('OTEL_DENO', 'true');

try {
const transactionEvents: any[] = [];

const client = init({
dsn: 'https://username@domain/123',
tracesSampleRate: 1,
beforeSendTransaction: () => null,
beforeSendTransaction: event => {
transactionEvents.push(event);
return null;
},
}) as DenoClient;

const provider = trace.getTracerProvider();
// Sentry's trace.disable() + setGlobalTracerProvider should have overridden
// any native Deno OTel provider, so spans go through Sentry's tracer.
const tracer = trace.getTracer('native-verification');
const span = tracer.startSpan('verification-span');

if (provider.constructor.name === 'Function') {
// Native OTel is active
assertNotEquals(span.constructor.name, 'NonRecordingSpan');

let contextWorks = false;
tracer.startActiveSpan('parent-span', parentSpan => {
if (trace.getActiveSpan() === parentSpan) {
contextWorks = true;
}
parentSpan.end();
});
assertEquals(contextWorks, true);
}

span.setAttributes({ 'test.native_otel': true });
span.end();

await client.flush();

assertEquals(transactionEvents.length, 1);
assertEquals(transactionEvents[0]?.transaction, 'verification-span');
assertEquals(transactionEvents[0]?.contexts?.trace?.data?.['sentry.deno_tracer'], true);
} finally {
// Restore original environment
if (originalValue === undefined) {
Expand Down
Loading