From 8876a67051e0f5fb5af45d73ea89da8e76709aa9 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 3 Sep 2026 14:04:03 +0200 Subject: [PATCH 1/2] test(e2e): Port `deno` E2E test app to span streaming Removes the `traceLifecycle: 'static'` pin from the `deno` app and rewrites its specs against streamed spans. Adds `deno-static` as a verbatim copy of the pre-port app to keep the static trace lifecycle covered, and removes `deno-streamed`, which is now a strict subset of `deno`. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 4 +- .../{deno-streamed => deno-static}/deno.json | 0 .../package.json | 14 +- .../playwright.config.mjs | 0 .../test-applications/deno-static/src/app.ts | 307 +++++++++++++++ .../start-event-proxy.mjs | 2 +- .../deno-static/tests/ai-error.test.ts | 36 ++ .../deno-static/tests/ai.test.ts | 62 +++ .../deno-static/tests/breadcrumbs.test.ts | 25 ++ .../deno-static/tests/context.test.ts | 34 ++ .../deno-static/tests/errors.test.ts | 15 + .../deno-static/tests/fetch.test.ts | 21 ++ .../deno-static/tests/logs.test.ts | 16 + .../deno-static/tests/metrics.test.ts | 67 ++++ .../deno-static/tests/scope.test.ts | 27 ++ .../deno-static/tests/transactions.test.ts | 97 +++++ .../deno-streamed/src/app.ts | 64 ---- .../deno-streamed/tests/spans.test.ts | 332 ---------------- .../test-applications/deno/package.json | 2 +- .../test-applications/deno/src/app.ts | 1 - .../deno/tests/ai-error.test.ts | 28 +- .../test-applications/deno/tests/ai.test.ts | 43 +-- .../deno/tests/fetch.test.ts | 23 +- .../deno/tests/transactions.test.ts | 355 ++++++++++++++---- 24 files changed, 1046 insertions(+), 529 deletions(-) rename dev-packages/e2e-tests/test-applications/{deno-streamed => deno-static}/deno.json (100%) rename dev-packages/e2e-tests/test-applications/{deno-streamed => deno-static}/package.json (63%) rename dev-packages/e2e-tests/test-applications/{deno-streamed => deno-static}/playwright.config.mjs (100%) create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/src/app.ts rename dev-packages/e2e-tests/test-applications/{deno-streamed => deno-static}/start-event-proxy.mjs (75%) create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5fe1893c994..2066addf640a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1041,7 +1041,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno if: - matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application == + matrix.test-application == 'deno' || matrix.test-application == 'deno-static' || matrix.test-application == 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' || matrix.test-application == 'deno-pg' uses: denoland/setup-deno@v2.0.5 @@ -1166,7 +1166,7 @@ jobs: with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Deno - if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' + if: matrix.test-application == 'deno' uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json b/dev-packages/e2e-tests/test-applications/deno-static/deno.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno-streamed/deno.json rename to dev-packages/e2e-tests/test-applications/deno-static/deno.json diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json b/dev-packages/e2e-tests/test-applications/deno-static/package.json similarity index 63% rename from dev-packages/e2e-tests/test-applications/deno-streamed/package.json rename to dev-packages/e2e-tests/test-applications/deno-static/package.json index 5f95becb0f0a..51b227c8d05f 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json +++ b/dev-packages/e2e-tests/test-applications/deno-static/package.json @@ -1,9 +1,9 @@ { - "name": "deno-streamed-app", + "name": "deno-static-app", "version": "1.0.0", "private": true, "scripts": { - "start": "deno run --allow-net --allow-env --allow-read --allow-sys src/app.ts", + "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", @@ -11,7 +11,7 @@ }, "dependencies": { "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api": "^1.9.0", "ai": "^3.0.0", "zod": "^3.22.4" }, @@ -21,13 +21,5 @@ }, "volta": { "extends": "../../package.json" - }, - "sentryTest": { - "optionalVariants": [ - { - "deno-version": "latest", - "label": "deno-streamed (latest)" - } - ] } } diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs rename to dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs diff --git a/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts new file mode 100644 index 000000000000..45da3a4209f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts @@ -0,0 +1,307 @@ +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'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +Sentry.init({ + traceLifecycle: 'static', + 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 }, async (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' }, + }); + } + + // Test breadcrumbs: add a breadcrumb then capture an error + if (url.pathname === '/test-breadcrumb') { + Sentry.addBreadcrumb({ + message: 'test-breadcrumb', + category: 'custom', + level: 'info', + }); + const exceptionId = Sentry.captureException(new Error('breadcrumb-test')); + return new Response(JSON.stringify({ exceptionId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test context: set user, tag, extra then capture an error + if (url.pathname === '/test-context') { + Sentry.setUser({ id: '123', email: 'test@sentry.io' }); + Sentry.setTag('deno-runtime', 'true'); + Sentry.setExtra('detail', { key: 'value' }); + const exceptionId = Sentry.captureException(new Error('context-test')); + return new Response(JSON.stringify({ exceptionId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test scope isolation: tags inside withScope do not leak + if (url.pathname === '/test-scope-isolation') { + let insideId: string | undefined; + let outsideId: string | undefined; + + Sentry.withScope(scope => { + scope.setTag('isolated', 'yes'); + insideId = Sentry.captureException(new Error('inside-scope')); + }); + + outsideId = Sentry.captureException(new Error('outside-scope')); + + return new Response(JSON.stringify({ insideId, outsideId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test outbound fetch instrumentation + if (url.pathname === '/test-outgoing-fetch') { + const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => { + const res = await fetch('http://localhost:3030/test-success'); + return res.json(); + }); + return new Response(JSON.stringify(response), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test AI: Vercel AI SDK generateText with mock model + if (url.pathname === '/test-ai') { + const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { + // First call - telemetry enabled by default + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second call - explicitly enabled telemetry + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third call - with tool calls + const result3 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async (args: { location: string }) => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth call - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Should not be captured!', + }), + }), + prompt: 'Where is the disabled span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; + }); + + return new Response(JSON.stringify(results), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test AI error: tool call that throws + if (url.pathname === '/test-ai-error') { + try { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async (_args: { location: string }) => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + }); + } catch (e) { + Sentry.captureException(e); + } + + return new Response(JSON.stringify({ status: 'error-handled' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test metrics: emit counter, distribution, and gauge + if (url.pathname === '/test-metrics') { + Sentry.metrics.count('test.deno.count', 1, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Apples', + }, + }); + Sentry.metrics.distribution('test.deno.distribution', 100, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Bananas', + }, + }); + Sentry.metrics.gauge('test.deno.gauge', 200, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Cherries', + }, + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test logs: emit a debug log via Sentry.logger + if (url.pathname === '/test-log') { + Sentry.logger.debug('Accessed /test-log route'); + return new Response(JSON.stringify({ message: 'Log sent' }), { + 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-streamed/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs similarity index 75% rename from dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs index a0c7bfc7222f..a9ac2fbee1cd 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'deno-streamed', + proxyServerName: 'deno-static', }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts new file mode 100644 index 000000000000..5edaa5000f91 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +test('should link AI errors to the correct trace', async ({ baseURL }) => { + const aiTransactionPromise = waitForTransaction('deno-static', event => { + return event?.spans?.some(span => span.description === 'ai-error-test') ?? false; + }); + + const errorEventPromise = waitForError('deno-static', event => { + return event.exception?.values?.[0]?.value?.includes('Tool call failed') ?? false; + }); + + await fetch(`${baseURL}/test-ai-error`); + + const aiTransaction = await aiTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(aiTransaction).toBeDefined(); + + const spans = aiTransaction.spans || []; + + // The parent span wrapping the AI call should exist + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ai-error-test', + op: 'function', + }), + ]), + ); + + expect(errorEvent).toBeDefined(); + + // Verify error is linked to the same trace as the transaction + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts new file mode 100644 index 000000000000..cf4d77de93bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) => { + const aiTransactionPromise = waitForTransaction('deno-static', event => { + return event?.spans?.some(span => span.description === 'ai-test') ?? false; + }); + + await fetch(`${baseURL}/test-ai`); + + const aiTransaction = await aiTransactionPromise; + + expect(aiTransaction).toBeDefined(); + + const spans = aiTransaction.spans || []; + + // The parent span wrapping all AI calls should exist + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ai-test', + op: 'function', + }), + ]), + ); + + // Vercel AI SDK emits OTel spans for generateText calls. + // Due to the AI SDK monkey-patching limitation (https://github.com/vercel/ai/pull/6716), + // only explicitly opted-in calls produce telemetry spans. + // The explicitly enabled call (experimental_telemetry: { isEnabled: true }) should produce spans. + const aiSpans = spans.filter((span: any) => { + if ( + span.op === 'gen_ai.invoke_agent' || + span.op === 'gen_ai.generate_content' || + span.op === 'gen_ai.execute_tool' + ) { + return true; + } + // Processed Vercel AI spans (incl. cases where OTel kind no longer maps to a generic `op`) + if (span.origin === 'auto.vercelai.otel') { + return true; + } + // Raw Vercel AI OTel span names / attributes before or without full Sentry mapping + if (typeof span.description === 'string' && span.description.startsWith('ai.')) { + return true; + } + if (span.data?.['ai.operationId'] != null || span.data?.['gen_ai.pipeline.name'] != null) { + return true; + } + return false; + }); + + // We expect at least one AI-related span from the explicitly enabled call + expect(aiSpans.length).toBeGreaterThanOrEqual(1); + + // Verify the disabled call was not captured + const promptsInSpans = spans + .map((span: any) => span.data?.['vercel.ai.prompt']) + .filter((prompt: unknown): prompt is string => prompt !== undefined); + const hasDisabledPrompt = promptsInSpans.some((prompt: string) => prompt.includes('Where is the disabled span?')); + expect(hasDisabledPrompt).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts new file mode 100644 index 000000000000..b995758f826b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends error event with breadcrumbs', async ({ baseURL }) => { + const errorEventPromise = waitForError('deno-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'breadcrumb-test'; + }); + + await fetch(`${baseURL}/test-breadcrumb`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('breadcrumb-test'); + + expect(errorEvent.breadcrumbs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'test-breadcrumb', + category: 'custom', + level: 'info', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts new file mode 100644 index 000000000000..5b7fae12fd4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends error event with user, tags, and extras', async ({ baseURL }) => { + const errorEventPromise = waitForError('deno-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'context-test'; + }); + + await fetch(`${baseURL}/test-context`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('context-test'); + + expect(errorEvent.user).toEqual( + expect.objectContaining({ + id: '123', + email: 'test@sentry.io', + }), + ); + + expect(errorEvent.tags).toEqual( + expect.objectContaining({ + 'deno-runtime': 'true', + }), + ); + + expect(errorEvent.extra).toEqual( + expect.objectContaining({ + detail: { key: 'value' }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts new file mode 100644 index 000000000000..1088141661b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/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-static', 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-static/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts new file mode 100644 index 000000000000..3386c6bcc7a8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno-static', event => { + return event?.spans?.some(span => span.description === 'test-outgoing-fetch') ?? false; + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-outgoing-fetch', + origin: 'manual', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts new file mode 100644 index 000000000000..04c7e08b2e93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +test('should send logs via Sentry.logger', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('deno-static', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts new file mode 100644 index 000000000000..c3af9b88a7db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +test('Should emit counter, distribution, and gauge metrics', async ({ baseURL }) => { + const countPromise = waitForMetric('deno-static', metric => { + return metric.name === 'test.deno.count'; + }); + + const distributionPromise = waitForMetric('deno-static', metric => { + return metric.name === 'test.deno.distribution'; + }); + + const gaugePromise = waitForMetric('deno-static', metric => { + return metric.name === 'test.deno.gauge'; + }); + + await fetch(`${baseURL}/test-metrics`); + + const count = await countPromise; + const distribution = await distributionPromise; + const gauge = await gaugePromise; + + expect(count).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.count', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Apples', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(distribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.distribution', + type: 'distribution', + value: 100, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Bananas', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(gauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.gauge', + type: 'gauge', + value: 200, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Cherries', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts new file mode 100644 index 000000000000..5224cdc71ff3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Scope isolation prevents tag leakage between scopes', async ({ baseURL }) => { + const insideErrorPromise = waitForError('deno-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'inside-scope'; + }); + + const outsideErrorPromise = waitForError('deno-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'outside-scope'; + }); + + await fetch(`${baseURL}/test-scope-isolation`); + + const insideError = await insideErrorPromise; + const outsideError = await outsideErrorPromise; + + // The error inside withScope should have the isolated tag + expect(insideError.tags).toEqual( + expect.objectContaining({ + isolated: 'yes', + }), + ); + + // The error outside withScope should NOT have the isolated tag + expect(outsideError.tags?.['isolated']).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts new file mode 100644 index 000000000000..75f925594005 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts @@ -0,0 +1,97 @@ +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-static', 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-static', 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', + origin: 'manual', + }), + ]), + ); + + const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-span'); + expect(otelSpan).toBeDefined(); + // INTERNAL (and other unmapped) kinds must not get a synthetic `otel.span` op + expect(otelSpan!.op).toBeUndefined(); +}); + +test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno-static', 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', + origin: 'manual', + }), + ]), + ); + + const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-active-span'); + expect(otelSpan).toBeDefined(); + expect(otelSpan!.op).toBeUndefined(); +}); + +test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno-static', 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', + 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); + expect(otelSpan!.op).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts deleted file mode 100644 index b5c6a606b949..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts +++ /dev/null @@ -1,64 +0,0 @@ -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/', - traceLifecycle: 'stream', - tracesSampleRate: 1, -}); - -const port = 3030; - -function flushDeferred() { - setTimeout(() => { - Sentry.flush(); - }, 100); -} - -Deno.serve({ port }, async (req: Request) => { - const url = new URL(req.url); - - // Test Sentry.startSpan — uses Sentry's internal pipeline - if (url.pathname === '/test-sentry-span') { - Sentry.startSpan({ name: 'test-sentry-span' }, () => { - // noop - }); - flushDeferred(); - 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(); - }); - flushDeferred(); - 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-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts deleted file mode 100644 index 9184084c85b8..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; - -const SEGMENT_SPAN = { - attributes: { - ['sentry.trace_lifecycle']: { - type: 'string', - value: 'stream', - }, - 'app.start_time': { - type: 'string', - value: expect.any(String), - }, - 'client.address': { - type: 'string', - value: expect.any(String), - }, - 'client.port': { - type: 'integer', - value: expect.any(Number), - }, - 'device.archs': { - type: 'array', - value: expect.any(Array), - }, - 'device.processor_count': { - type: 'integer', - value: expect.any(Number), - }, - 'http.request.header.accept': { - type: 'string', - value: '*/*', - }, - 'http.request.header.accept_encoding': { - type: 'string', - value: 'gzip, deflate', - }, - 'http.request.header.accept_language': { - type: 'string', - value: '*', - }, - 'http.request.header.connection': { - type: 'string', - value: 'keep-alive', - }, - 'http.request.header.host': { - type: 'string', - value: expect.stringMatching(/^localhost:\d+$/), - }, - 'http.request.header.sec_fetch_mode': { - type: 'string', - value: 'cors', - }, - 'http.request.header.user_agent': { - type: 'string', - value: 'node', - }, - 'http.request.method': { - type: 'string', - value: 'GET', - }, - 'http.response.header.content_type': { - type: 'string', - value: 'application/json', - }, - 'http.response.status_code': { - type: 'integer', - value: expect.any(Number), - }, - 'network.protocol.name': { - type: 'string', - value: 'http', - }, - 'os.name': { - type: 'string', - value: expect.any(String), - }, - 'os.version': { - type: 'string', - value: expect.any(String), - }, - 'sentry.environment': { - type: 'string', - value: 'qa', - }, - 'sentry.op': { - type: 'string', - value: 'http.server', - }, - 'sentry.origin': { - type: 'string', - value: 'auto.http.deno', - }, - 'sentry.sample_rate': { - type: 'integer', - value: 1, - }, - 'sentry.sdk.name': { - type: 'string', - value: 'sentry.javascript.deno', - }, - 'sentry.sdk.version': { - type: 'string', - value: expect.any(String), - }, - 'sentry.sdk.integrations': { - type: 'array', - value: expect.arrayContaining(['SpanStreaming']), - }, - 'sentry.segment.id': { - type: 'string', - value: expect.stringMatching(/^[\da-f]{16}$/), - }, - 'sentry.segment.name': { - type: 'string', - value: 'GET', - }, - 'sentry.segment.name.source': { - type: 'string', - value: 'url', - }, - 'server.address': { - type: 'string', - value: expect.any(String), - }, - 'url.full': { - type: 'string', - value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-sentry-span$/), - }, - 'url.path': { - type: 'string', - value: '/test-sentry-span', - }, - 'url.port': { - type: 'string', - value: expect.any(String), - }, - 'url.scheme': { - type: 'string', - value: 'http:', - }, - 'user_agent.original': { - type: 'string', - value: 'node', - }, - 'process.runtime.engine.name': { - type: 'string', - value: 'v8', - }, - 'process.runtime.engine.version': { - type: 'string', - value: expect.any(String), - }, - }, - end_timestamp: expect.any(Number), - is_segment: true, - name: 'GET', - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - status: 'ok', - trace_id: expect.stringMatching(/^[\da-f]{32}$/), -}; - -test('Sends streamed spans (http.server and manual with Sentry.startSpan)', async ({ baseURL }) => { - const spansPromise = waitForStreamedSpans('deno-streamed', spans => { - return spans.some(span => span.name === 'test-sentry-span'); - }); - - await fetch(`${baseURL}/test-sentry-span`); - - const spans = await spansPromise; - expect(spans).toHaveLength(2); - - expect(spans).toEqual([ - { - attributes: { - ['sentry.trace_lifecycle']: { - type: 'string', - value: 'stream', - }, - 'sentry.environment': { - type: 'string', - value: 'qa', - }, - 'sentry.origin': { - type: 'string', - value: 'manual', - }, - 'sentry.sdk.name': { - type: 'string', - value: 'sentry.javascript.deno', - }, - 'sentry.sdk.version': { - type: 'string', - value: expect.any(String), - }, - 'sentry.segment.id': { - type: 'string', - value: expect.stringMatching(/^[\da-f]{16}$/), - }, - 'sentry.segment.name': { - type: 'string', - value: 'GET', - }, - }, - end_timestamp: expect.any(Number), - is_segment: false, - name: 'test-sentry-span', - parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - status: 'ok', - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - }, - SEGMENT_SPAN, - ]); -}); - -test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { - const spansPromise = waitForStreamedSpans('deno-streamed', spans => { - return spans.some(span => span.name === 'sentry-parent'); - }); - - await fetch(`${baseURL}/test-interop`); - - const spans = await spansPromise; - - expect(spans).toHaveLength(3); - - const httpServerSpan = spans.find(span => getSpanOp(span) === 'http.server'); - expect(httpServerSpan).toEqual({ - ...SEGMENT_SPAN, - name: 'GET', - attributes: { - ...SEGMENT_SPAN.attributes, - 'sentry.segment.name': { type: 'string', value: 'GET' }, - 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) }, - 'url.path': { type: 'string', value: '/test-interop' }, - }, - }); - // Verify the OTel span is a child of the Sentry span - const sentrySpan = spans.find(span => span.name === 'sentry-parent'); - const otelSpan = spans.find(span => span.name === 'otel-child'); - - expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id); - - expect(sentrySpan).toEqual({ - attributes: { - ['sentry.trace_lifecycle']: { - type: 'string', - value: 'stream', - }, - 'sentry.environment': { - type: 'string', - value: 'qa', - }, - 'sentry.origin': { - type: 'string', - value: 'manual', - }, - 'sentry.sdk.name': { - type: 'string', - value: 'sentry.javascript.deno', - }, - 'sentry.sdk.version': { - type: 'string', - value: expect.any(String), - }, - 'sentry.segment.id': { - type: 'string', - value: expect.stringMatching(/^[\da-f]{16}$/), - }, - 'sentry.segment.name': { - type: 'string', - value: 'GET', - }, - }, - end_timestamp: expect.any(Number), - is_segment: false, - name: 'sentry-parent', - parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - status: 'ok', - trace_id: httpServerSpan!.trace_id, - }); - - expect(otelSpan).toEqual({ - attributes: { - ['sentry.trace_lifecycle']: { - type: 'string', - value: 'stream', - }, - 'sentry.environment': { - type: 'string', - value: 'qa', - }, - 'sentry.origin': { - type: 'string', - value: 'manual', - }, - 'sentry.sdk.name': { - type: 'string', - value: 'sentry.javascript.deno', - }, - 'sentry.sdk.version': { - type: 'string', - value: expect.any(String), - }, - 'sentry.segment.id': { - type: 'string', - value: expect.stringMatching(/^[\da-f]{16}$/), - }, - 'sentry.segment.name': { - type: 'string', - value: 'GET', - }, - 'sentry.deno_tracer': { - type: 'boolean', - value: true, - }, - }, - end_timestamp: expect.any(Number), - is_segment: false, - name: 'otel-child', - parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - status: 'ok', - trace_id: httpServerSpan!.trace_id, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json index d408ed2816db..01642e203543 100644 --- a/dev-packages/e2e-tests/test-applications/deno/package.json +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "deno run --allow-net --allow-env --allow-read src/app.ts", + "start": "deno run --allow-net --allow-env --allow-read --allow-sys src/app.ts", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", diff --git a/dev-packages/e2e-tests/test-applications/deno/src/app.ts b/dev-packages/e2e-tests/test-applications/deno/src/app.ts index 45da3a4209f8..71dba56c3c3b 100644 --- a/dev-packages/e2e-tests/test-applications/deno/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -18,7 +18,6 @@ import { MockLanguageModelV1 } from 'ai/test'; import { z } from 'zod'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', dsn: Deno.env.get('E2E_TEST_DSN'), debug: !!Deno.env.get('DEBUG'), diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts index 8cf82e56de15..67358e722a9d 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts @@ -1,10 +1,8 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('should link AI errors to the correct trace', async ({ baseURL }) => { - const aiTransactionPromise = waitForTransaction('deno', event => { - return event?.spans?.some(span => span.description === 'ai-error-test') ?? false; - }); + const aiSpanPromise = waitForStreamedSpan('deno', span => span.name === 'ai-error-test'); const errorEventPromise = waitForError('deno', event => { return event.exception?.values?.[0]?.value?.includes('Tool call failed') ?? false; @@ -12,25 +10,21 @@ test('should link AI errors to the correct trace', async ({ baseURL }) => { await fetch(`${baseURL}/test-ai-error`); - const aiTransaction = await aiTransactionPromise; + const aiSpan = await aiSpanPromise; const errorEvent = await errorEventPromise; - expect(aiTransaction).toBeDefined(); - - const spans = aiTransaction.spans || []; - // The parent span wrapping the AI call should exist - expect(spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'ai-error-test', - op: 'function', + expect(aiSpan).toEqual( + expect.objectContaining({ + name: 'ai-error-test', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'function' }, }), - ]), + }), ); expect(errorEvent).toBeDefined(); - // Verify error is linked to the same trace as the transaction - expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); + // Verify error is linked to the same trace as the span + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiSpan.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts index 0ba5f463d65d..30d108b80463 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts @@ -1,25 +1,25 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) => { - const aiTransactionPromise = waitForTransaction('deno', event => { - return event?.spans?.some(span => span.description === 'ai-test') ?? false; - }); + // The `ai-test` span wraps all AI calls, so once the trace's segment has arrived every AI span has too. + const spansPromise = collectStreamedSpans( + 'deno', + spans => spans.some(span => span.name === 'ai-test') && spans.some(span => span.is_segment), + ); await fetch(`${baseURL}/test-ai`); - const aiTransaction = await aiTransactionPromise; - - expect(aiTransaction).toBeDefined(); - - const spans = aiTransaction.spans || []; + const spans = await spansPromise; // The parent span wrapping all AI calls should exist expect(spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'ai-test', - op: 'function', + name: 'ai-test', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'function' }, + }), }), ]), ); @@ -28,23 +28,20 @@ test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) = // Due to the AI SDK monkey-patching limitation (https://github.com/vercel/ai/pull/6716), // only explicitly opted-in calls produce telemetry spans. // The explicitly enabled call (experimental_telemetry: { isEnabled: true }) should produce spans. - const aiSpans = spans.filter((span: any) => { - if ( - span.op === 'gen_ai.invoke_agent' || - span.op === 'gen_ai.generate_content' || - span.op === 'gen_ai.execute_tool' - ) { + const aiSpans = spans.filter(span => { + const op = getSpanOp(span); + if (op === 'gen_ai.invoke_agent' || op === 'gen_ai.generate_content' || op === 'gen_ai.execute_tool') { return true; } // Processed Vercel AI spans (incl. cases where OTel kind no longer maps to a generic `op`) - if (span.origin === 'auto.vercelai.otel') { + if (span.attributes['sentry.origin']?.value === 'auto.vercelai.otel') { return true; } // Raw Vercel AI OTel span names / attributes before or without full Sentry mapping - if (typeof span.description === 'string' && span.description.startsWith('ai.')) { + if (span.name.startsWith('ai.')) { return true; } - if (span.data?.['ai.operationId'] != null || span.data?.['gen_ai.pipeline.name'] != null) { + if (span.attributes['ai.operationId'] != null || span.attributes['gen_ai.pipeline.name'] != null) { return true; } return false; @@ -55,8 +52,8 @@ test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) = // Verify the disabled call was not captured const promptsInSpans = spans - .map((span: any) => span.data?.['vercel.ai.prompt']) - .filter((prompt: unknown): prompt is string => prompt !== undefined); - const hasDisabledPrompt = promptsInSpans.some((prompt: string) => prompt.includes('Where is the disabled span?')); + .map(span => span.attributes['vercel.ai.prompt']?.value) + .filter((prompt): prompt is string => typeof prompt === 'string'); + const hasDisabledPrompt = promptsInSpans.some(prompt => prompt.includes('Where is the disabled span?')); expect(hasDisabledPrompt).toBe(false); }); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts index 7a0dcb30c82e..d2f46041618d 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts @@ -1,21 +1,20 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno', event => { - return event?.spans?.some(span => span.description === 'test-outgoing-fetch') ?? false; - }); +test('Outbound fetch inside Sentry span creates streamed span', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('deno', span => span.name === 'test-outgoing-fetch'); await fetch(`${baseURL}/test-outgoing-fetch`); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'test-outgoing-fetch', - origin: 'manual', + expect(span).toEqual( + expect.objectContaining({ + name: 'test-outgoing-fetch', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'manual' }, }), - ]), + }), ); }); 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 index 19077bb76b75..cdc275e09c38 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts @@ -1,97 +1,322 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } 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; - }); +// `Deno.serve` has no route information, so with span streaming the http.server segment is +// named after the method only and the path lives in `url.path`. +function collectRequestSpans(path: string) { + return collectStreamedSpans('deno', spans => + spans.some( + span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path, + ), + ); +} + +const SEGMENT_SPAN = { + attributes: { + ['sentry.trace_lifecycle']: { + type: 'string', + value: 'stream', + }, + 'app.start_time': { + type: 'string', + value: expect.any(String), + }, + 'client.address': { + type: 'string', + value: expect.any(String), + }, + 'client.port': { + type: 'integer', + value: expect.any(Number), + }, + 'device.archs': { + type: 'array', + value: expect.any(Array), + }, + 'device.processor_count': { + type: 'integer', + value: expect.any(Number), + }, + 'http.request.header.accept': { + type: 'string', + value: '*/*', + }, + 'http.request.header.accept_encoding': { + type: 'string', + value: 'gzip, deflate', + }, + 'http.request.header.accept_language': { + type: 'string', + value: '*', + }, + 'http.request.header.connection': { + type: 'string', + value: 'keep-alive', + }, + 'http.request.header.host': { + type: 'string', + value: expect.stringMatching(/^localhost:\d+$/), + }, + 'http.request.header.sec_fetch_mode': { + type: 'string', + value: 'cors', + }, + 'http.request.header.user_agent': { + type: 'string', + value: 'node', + }, + 'http.request.method': { + type: 'string', + value: 'GET', + }, + 'http.response.header.content_type': { + type: 'string', + value: 'application/json', + }, + 'http.response.status_code': { + type: 'integer', + value: expect.any(Number), + }, + 'network.protocol.name': { + type: 'string', + value: 'http', + }, + 'os.name': { + type: 'string', + value: expect.any(String), + }, + 'os.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.op': { + type: 'string', + value: 'http.server', + }, + 'sentry.origin': { + type: 'string', + value: 'auto.http.deno', + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.sdk.integrations': { + type: 'array', + value: expect.arrayContaining(['SpanStreaming']), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET', + }, + 'sentry.segment.name.source': { + type: 'string', + value: 'url', + }, + 'server.address': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-sentry-span$/), + }, + 'url.path': { + type: 'string', + value: '/test-sentry-span', + }, + 'url.port': { + type: 'string', + value: expect.any(String), + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + 'process.runtime.engine.name': { + type: 'string', + value: 'v8', + }, + 'process.runtime.engine.version': { + type: 'string', + value: expect.any(String), + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'GET', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), +}; + +const CHILD_SPAN_ATTRIBUTES = { + ['sentry.trace_lifecycle']: { + type: 'string', + value: 'stream', + }, + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET', + }, +}; + +test('Sends streamed spans (http.server and manual with Sentry.startSpan)', async ({ baseURL }) => { + const spansPromise = collectRequestSpans('/test-sentry-span'); await fetch(`${baseURL}/test-sentry-span`); - const transaction = await transactionPromise; + const spans = await spansPromise; + expect(spans).toHaveLength(2); - expect(transaction.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'test-sentry-span', - origin: 'manual', - }), - ]), - ); -}); + const httpServerSpan = spans.find(span => span.is_segment); + expect(httpServerSpan).toEqual(SEGMENT_SPAN); -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; + const sentrySpan = spans.find(span => span.name === 'test-sentry-span'); + expect(sentrySpan).toEqual({ + attributes: CHILD_SPAN_ATTRIBUTES, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-sentry-span', + parent_span_id: httpServerSpan!.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, }); +}); + +test('Sends streamed span with OTel tracer.startSpan despite pre-existing provider', async ({ baseURL }) => { + const spansPromise = collectRequestSpans('/test-otel-span'); await fetch(`${baseURL}/test-otel-span`); - const transaction = await transactionPromise; + const spans = await spansPromise; - expect(transaction.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'test-otel-span', - origin: 'manual', + const otelSpan = spans.find(span => span.name === 'test-otel-span'); + expect(otelSpan).toEqual( + expect.objectContaining({ + name: 'test-otel-span', + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'manual' }, }), - ]), + }), ); - - const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-span'); - expect(otelSpan).toBeDefined(); // INTERNAL (and other unmapped) kinds must not get a synthetic `otel.span` op - expect(otelSpan!.op).toBeUndefined(); + expect(getSpanOp(otelSpan!)).toBeUndefined(); }); -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; - }); +test('Sends streamed span with OTel tracer.startActiveSpan', async ({ baseURL }) => { + const spansPromise = collectRequestSpans('/test-otel-active-span'); await fetch(`${baseURL}/test-otel-active-span`); - const transaction = await transactionPromise; + const spans = await spansPromise; - expect(transaction.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'test-otel-active-span', - origin: 'manual', + const otelSpan = spans.find(span => span.name === 'test-otel-active-span'); + expect(otelSpan).toEqual( + expect.objectContaining({ + name: 'test-otel-active-span', + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'manual' }, }), - ]), + }), ); - - const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-active-span'); - expect(otelSpan).toBeDefined(); - expect(otelSpan!.op).toBeUndefined(); + expect(getSpanOp(otelSpan!)).toBeUndefined(); }); 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; - }); + const spansPromise = collectRequestSpans('/test-interop'); await fetch(`${baseURL}/test-interop`); - const transaction = await transactionPromise; + const spans = await spansPromise; - expect(transaction.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'sentry-parent', - origin: 'manual', - }), - expect.objectContaining({ - description: 'otel-child', - origin: 'manual', - }), - ]), - ); + expect(spans).toHaveLength(3); - // 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); - expect(otelSpan!.op).toBeUndefined(); + const httpServerSpan = spans.find(span => span.is_segment); + expect(httpServerSpan).toEqual({ + ...SEGMENT_SPAN, + attributes: { + ...SEGMENT_SPAN.attributes, + 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) }, + 'url.path': { type: 'string', value: '/test-interop' }, + }, + }); + + const sentrySpan = spans.find(span => span.name === 'sentry-parent'); + const otelSpan = spans.find(span => span.name === 'otel-child'); + + expect(sentrySpan).toEqual({ + attributes: CHILD_SPAN_ATTRIBUTES, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'sentry-parent', + parent_span_id: httpServerSpan!.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); + + // The OTel span is a child of the Sentry span + expect(otelSpan).toEqual({ + attributes: { + ...CHILD_SPAN_ATTRIBUTES, + 'sentry.deno_tracer': { + type: 'boolean', + value: true, + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'otel-child', + parent_span_id: sentrySpan!.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); + expect(getSpanOp(otelSpan!)).toBeUndefined(); }); From 6a127d629b8d68d19336155b7ce199dcfc38f759 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 4 Sep 2026 09:50:06 +0200 Subject: [PATCH 2/2] test(e2e): Align Deno OpenTelemetry API version Co-Authored-By: OpenAI Codex --- .../e2e-tests/test-applications/deno-static/package.json | 2 +- dev-packages/e2e-tests/test-applications/deno/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/deno-static/package.json b/dev-packages/e2e-tests/test-applications/deno-static/package.json index 51b227c8d05f..a3078405f8c6 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/package.json +++ b/dev-packages/e2e-tests/test-applications/deno-static/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "ai": "^3.0.0", "zod": "^3.22.4" }, diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json index 01642e203543..e02eca4b4514 100644 --- a/dev-packages/e2e-tests/test-applications/deno/package.json +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "ai": "^3.0.0", "zod": "^3.22.4" },