Skip to content
Open
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 @@ -1017,6 +1017,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
36 changes: 36 additions & 0 deletions dev-packages/e2e-tests/lib/copyToTemp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export async function copyToTemp(originalPath: string, tmpDirPath: string): Prom
await cp(originalPath, tmpDirPath, { recursive: true });

fixPackageJson(tmpDirPath);
fixDenoJson(tmpDirPath);
}

function fixPackageJson(cwd: string): void {
Expand Down Expand Up @@ -59,3 +60,38 @@ function fixFileLinkDependencies(dependencyObj: Record<string, string>): void {
}
}
}

function fixDenoJson(cwd: string): void {
const denoJsonPath = join(cwd, 'deno.json');

let raw: string;
try {
raw = readFileSync(denoJsonPath, 'utf8');
} catch {
return;
}

const denoJson = JSON.parse(raw) as {
imports?: Record<string, string>;
};

if (!denoJson.imports) {
return;
}

let changed = false;
for (const [key, value] of Object.entries(denoJson.imports)) {
// Fix relative paths (not npm: or https: specifiers)
if (value.startsWith('.') || value.startsWith('/')) {
// Same virtual-dir trick as link: deps to get consistent relative depth
const newPath = join(__dirname, 'virtual-dir/', value);
denoJson.imports[key] = newPath;
console.log(`Fixed deno.json import ${key} to ${newPath}`);
changed = true;
}
}

if (changed) {
writeFileSync(denoJsonPath, JSON.stringify(denoJson, null, 2));
}
}
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
7 changes: 7 additions & 0 deletions dev-packages/e2e-tests/test-applications/deno/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"imports": {
"@sentry/deno": "../../../../packages/deno/build/esm/index.js",
"@sentry/core": "../../../../packages/core/build/esm/index.js",
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0"
}
}
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

trace.setGlobalTracerProvider(new SentryDenoTraceProvider());
}

Expand Down
Loading
Loading