diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b84a70ffbd6..39ca1e71cbb4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: diff --git a/dev-packages/e2e-tests/lib/copyToTemp.ts b/dev-packages/e2e-tests/lib/copyToTemp.ts index 830ff76f6077..83b7ce352b85 100644 --- a/dev-packages/e2e-tests/lib/copyToTemp.ts +++ b/dev-packages/e2e-tests/lib/copyToTemp.ts @@ -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 { @@ -59,3 +60,38 @@ function fixFileLinkDependencies(dependencyObj: Record): 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; + }; + + 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)); + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno/.npmrc b/dev-packages/e2e-tests/test-applications/deno/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/deno/deno.json b/dev-packages/e2e-tests/test-applications/deno/deno.json new file mode 100644 index 000000000000..e68e7e00c78a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/deno.json @@ -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" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json new file mode 100644 index 000000000000..8ec92fbd3985 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -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" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs new file mode 100644 index 000000000000..3d3ab7d8df02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno/src/app.ts b/dev-packages/e2e-tests/test-applications/deno/src/app.ts new file mode 100644 index 000000000000..fb34053e29d7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -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}`); diff --git a/dev-packages/e2e-tests/test-applications/deno/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno/start-event-proxy.mjs new file mode 100644 index 000000000000..a97ce6aa005c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'deno', +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts new file mode 100644 index 000000000000..5b4018291a18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/errors.test.ts @@ -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'); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts new file mode 100644 index 000000000000..3cd0892cebdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts @@ -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); +}); diff --git a/packages/deno/src/opentelemetry/tracer.ts b/packages/deno/src/opentelemetry/tracer.ts index 3176616bc04c..7bc704446d37 100644 --- a/packages/deno/src/opentelemetry/tracer.ts +++ b/packages/deno/src/opentelemetry/tracer.ts @@ -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(); trace.setGlobalTracerProvider(new SentryDenoTraceProvider()); } diff --git a/packages/deno/test/opentelemetry.test.ts b/packages/deno/test/opentelemetry.test.ts index 30723e033dd4..492dead3339c 100644 --- a/packages/deno/test/opentelemetry.test.ts +++ b/packages/deno/test/opentelemetry.test.ts @@ -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 @@ -238,7 +239,7 @@ 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 @@ -246,34 +247,29 @@ Deno.test('should verify native Deno OpenTelemetry works when enabled', async () 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) {