From 6888c1f70b9bf4b0329bf9a363f5f17aeedf1431 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 3 Sep 2026 13:22:43 +0200 Subject: [PATCH 1/2] test(e2e): Port Bun E2E test apps to span streaming Ports `elysia-bun`, `bun-mysql` and `bun-bytecode` plus the `bun-integration-tests` suites (`basic`, `fetch`, `hono-sdk`) to span streaming, and adds `elysia-bun-static` as a verbatim copy of the pre-port `elysia-bun` to keep the static trace lifecycle covered. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 4 +- .../suites/basic/index.ts | 1 - .../suites/basic/test.ts | 4 +- .../suites/fetch/index.ts | 7 +- .../suites/fetch/test.ts | 69 +++--- .../suites/hono-sdk/index.ts | 1 - .../suites/hono-sdk/test.ts | 89 +++----- .../bun-bytecode/src/main.ts | 1 - .../test-applications/bun-mysql/src/app.ts | 1 - .../bun-mysql/tests/mysql.test.ts | 79 ++++--- .../elysia-bun-static/.gitignore | 1 + .../elysia-bun-static/package.json | 25 ++ .../elysia-bun-static/playwright.config.mjs | 7 + .../elysia-bun-static/src/app.ts | 143 ++++++++++++ .../elysia-bun-static/start-event-proxy.mjs | 6 + .../elysia-bun-static/tests/errors.test.ts | 109 +++++++++ .../elysia-bun-static/tests/isolation.test.ts | 30 +++ .../tests/propagation.test.ts | 113 +++++++++ .../tests/transactions.test.ts | 216 ++++++++++++++++++ .../elysia-bun-static/tsconfig.json | 11 + .../test-applications/elysia-bun/src/app.ts | 1 - .../elysia-bun/tests/errors.test.ts | 10 +- .../elysia-bun/tests/propagation.test.ts | 44 ++-- .../elysia-bun/tests/transactions.test.ts | 199 ++++++++-------- 24 files changed, 904 insertions(+), 267 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/isolation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/elysia-bun-static/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5fe1893c994..943b02cbd7c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1028,8 +1028,8 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun if: - contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "hono-4", "bun-bytecode", - "bun-mysql"]'), matrix.test-application) + contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4", + "bun-bytecode", "bun-mysql"]'), matrix.test-application) uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' diff --git a/dev-packages/bun-integration-tests/suites/basic/index.ts b/dev-packages/bun-integration-tests/suites/basic/index.ts index e357d8866a89..c760e61d6ba5 100644 --- a/dev-packages/bun-integration-tests/suites/basic/index.ts +++ b/dev-packages/bun-integration-tests/suites/basic/index.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/bun'; Sentry.init({ - traceLifecycle: 'static', dsn: process.env.SENTRY_DSN, tracesSampleRate: 1.0, }); diff --git a/dev-packages/bun-integration-tests/suites/basic/test.ts b/dev-packages/bun-integration-tests/suites/basic/test.ts index c03a09535702..9a9da240e373 100644 --- a/dev-packages/bun-integration-tests/suites/basic/test.ts +++ b/dev-packages/bun-integration-tests/suites/basic/test.ts @@ -28,7 +28,7 @@ it('captures an error thrown in Bun.serve fetch handler', async ({ signal }) => { includeSampleRand: true, includeTransaction: false }, ), ) - .ignore('transaction') + .ignore('span') .start(signal); await runner.makeRequest('get', '/error', { expectError: true }); await runner.completed(); @@ -47,7 +47,7 @@ it('captures a manually sent message', async ({ signal }) => { message: 'Hello from Bun', }); }) - .ignore('transaction') + .ignore('span') .start(signal); await runner.makeRequest('get', '/message'); await runner.completed(); diff --git a/dev-packages/bun-integration-tests/suites/fetch/index.ts b/dev-packages/bun-integration-tests/suites/fetch/index.ts index 972d6a3aba45..2f3780bda0a0 100644 --- a/dev-packages/bun-integration-tests/suites/fetch/index.ts +++ b/dev-packages/bun-integration-tests/suites/fetch/index.ts @@ -13,7 +13,6 @@ const targetServer = Bun.serve({ const targetUrl = `http://localhost:${targetServer.port}`; Sentry.init({ - traceLifecycle: 'static', environment: 'production', dsn: process.env.SENTRY_DSN, tracesSampleRate: 1.0, @@ -39,6 +38,12 @@ const server = Bun.serve({ return Response.json(data); } + if (url.pathname === '/outgoing-fetch-message') { + await fetch(`${targetUrl}/allowed`); + Sentry.captureMessage('fetch done'); + return new Response('OK'); + } + return new Response('Hello from Bun!'); }, }); diff --git a/dev-packages/bun-integration-tests/suites/fetch/test.ts b/dev-packages/bun-integration-tests/suites/fetch/test.ts index 7a168d9a8a84..7aa91ec30cd7 100644 --- a/dev-packages/bun-integration-tests/suites/fetch/test.ts +++ b/dev-packages/bun-integration-tests/suites/fetch/test.ts @@ -1,28 +1,38 @@ -import type { Envelope, TransactionEvent } from '@sentry/core'; +import type { Envelope, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; -function getTransaction(envelope: Envelope): TransactionEvent { - return envelope[1][0][1] as TransactionEvent; +function getSpans(envelope: Envelope): SerializedStreamedSpan[] { + return (envelope[1][0][1] as SerializedStreamedSpanContainer).items; } it('creates an http.client span for outgoing fetch requests', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transaction = getTransaction(envelope); - - expect(transaction.transaction).toBe('GET /outgoing-fetch'); + const spans = getSpans(envelope); + + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan).toMatchObject({ + // `Bun.serve` without `routes` has no parameterized route, so the streamed segment is + // named after the method only; the path lives in `url.path`. + name: 'GET', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'url.path': { value: '/outgoing-fetch', type: 'string' }, + }), + }); - const httpClientSpan = transaction.spans?.find(span => span.op === 'http.client'); + const httpClientSpan = spans.find(span => span.attributes['sentry.op']?.value === 'http.client'); expect(httpClientSpan).toBeDefined(); expect(httpClientSpan).toMatchObject({ - op: 'http.client', - origin: 'auto.http.fetch', - description: expect.stringMatching(/^GET http:\/\/localhost:\d+\/allowed$/), - data: expect.objectContaining({ - 'http.request.method': 'GET', - type: 'fetch', + name: 'GET localhost', + parent_span_id: segmentSpan!.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.fetch', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + type: { value: 'fetch', type: 'string' }, }), }); }) @@ -54,25 +64,30 @@ it('does not propagate headers to outgoing fetch requests outside tracePropagati }); it('records a breadcrumb for outgoing fetch requests', async ({ signal }) => { + // Streamed spans carry no breadcrumbs, so the breadcrumb is asserted on a message + // captured right after the fetch instead. const runner = createRunner(__dirname) .expect(envelope => { - const transaction = getTransaction(envelope); - - const fetchBreadcrumb = transaction.breadcrumbs?.find( - breadcrumb => breadcrumb.category === 'fetch' && (breadcrumb.data?.url as string)?.includes('/allowed'), - ); - - expect(fetchBreadcrumb).toMatchObject({ - category: 'fetch', - type: 'http', - data: expect.objectContaining({ - method: 'GET', - status_code: 200, + const [, envelopeItems] = envelope; + const [itemHeader, event] = envelopeItems[0] as [{ type: string }, { breadcrumbs?: unknown[] }]; + + expect(itemHeader.type).toBe('event'); + + expect(event.breadcrumbs).toContainEqual( + expect.objectContaining({ + category: 'fetch', + type: 'http', + data: expect.objectContaining({ + method: 'GET', + status_code: 200, + url: expect.stringMatching(/\/allowed$/), + }), }), - }); + ); }) + .ignore('span') .start(signal); - await runner.makeRequest('get', '/outgoing-fetch'); + await runner.makeRequest('get', '/outgoing-fetch-message'); await runner.completed(); }); diff --git a/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts b/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts index 0b2ae38f6ad0..075fc896618b 100644 --- a/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts +++ b/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts @@ -7,7 +7,6 @@ app.use( sentry(app, { dsn: process.env.SENTRY_DSN, tracesSampleRate: 1.0, - traceLifecycle: 'static', }), ); diff --git a/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts b/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts index fa73b3648b34..785cffb85589 100644 --- a/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts @@ -1,3 +1,4 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { expect, it } from 'vitest'; import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; import { createRunner } from '../../runner'; @@ -8,43 +9,25 @@ it('Hono app captures parametrized errors (Hono SDK on Bun)', async ({ signal }) const [, envelopeItems] = envelope; const [itemHeader, itemPayload] = envelopeItems[0]; - expect(itemHeader.type).toBe('transaction'); + expect(itemHeader.type).toBe('span'); - expect(itemPayload).toMatchObject({ - type: 'transaction', - platform: 'node', - transaction: 'GET /error/:param', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'http.server', - status: 'internal_error', - origin: 'auto.http.bun.serve', - }, - response: { - status_code: 500, - }, - }, - request: expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/error/param-123'), + const segmentSpan = (itemPayload as SerializedStreamedSpanContainer).items.find(span => span.is_segment); + + expect(segmentSpan).toMatchObject({ + name: 'GET /error/:param', + is_segment: true, + span_id: expect.any(String), + trace_id: expect.any(String), + status: 'error', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.route': { value: '/error/:param', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 500, type: 'integer' }, + 'url.path': { value: '/error/param-123', type: 'string' }, }), - breadcrumbs: [ - { - timestamp: expect.any(Number), - category: 'console', - level: 'error', - message: 'Error: Test error from Hono app', - data: expect.objectContaining({ - logger: 'console', - arguments: [{ message: 'Test error from Hono app', name: 'Error', stack: expect.any(String) }], - }), - }, - ], }); }) @@ -101,27 +84,23 @@ it('Hono app captures parametrized route names on Bun', async ({ signal }) => { const [, envelopeItems] = envelope; const [itemHeader, itemPayload] = envelopeItems[0]; - expect(itemHeader.type).toBe('transaction'); + expect(itemHeader.type).toBe('span'); - expect(itemPayload).toMatchObject({ - type: 'transaction', - platform: 'node', - transaction: 'GET /hello/:name', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - span_id: SHORT_UUID_MATCHER, - trace_id: UUID_MATCHER, - op: 'http.server', - status: 'ok', - origin: 'auto.http.bun.serve', - }, - }, - request: expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/hello/world'), + const segmentSpan = (itemPayload as SerializedStreamedSpanContainer).items.find(span => span.is_segment); + + expect(segmentSpan).toMatchObject({ + name: 'GET /hello/:name', + is_segment: true, + span_id: SHORT_UUID_MATCHER, + trace_id: UUID_MATCHER, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.route': { value: '/hello/:name', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.path': { value: '/hello/world', type: 'string' }, }), }); }) diff --git a/dev-packages/e2e-tests/test-applications/bun-bytecode/src/main.ts b/dev-packages/e2e-tests/test-applications/bun-bytecode/src/main.ts index 9aa826d68749..bc5783127d58 100644 --- a/dev-packages/e2e-tests/test-applications/bun-bytecode/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/bun-bytecode/src/main.ts @@ -4,7 +4,6 @@ import * as Sentry from '@sentry/bun'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 0, }); diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/src/app.ts b/dev-packages/e2e-tests/test-applications/bun-mysql/src/app.ts index a12f2f6c77ba..1deaf29d8fc7 100644 --- a/dev-packages/e2e-tests/test-applications/bun-mysql/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/src/app.ts @@ -4,7 +4,6 @@ import * as Sentry from '@sentry/bun'; import mysql from 'mysql'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', dsn: process.env.E2E_TEST_DSN, debug: !!process.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/tests/mysql.test.ts b/dev-packages/e2e-tests/test-applications/bun-mysql/tests/mysql.test.ts index 1a70ac0cd0cf..8437061fd1bd 100644 --- a/dev-packages/e2e-tests/test-applications/bun-mysql/tests/mysql.test.ts +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/tests/mysql.test.ts @@ -1,55 +1,64 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; + +// `Bun.serve` without `routes` has no parameterized route, so with span streaming the +// http.server segment is named after the method only; the path lives in `url.path`. +function isTestMysqlSegment(span: SerializedStreamedSpan): boolean { + return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-mysql'; +} test('mysql queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => { - // Each incoming request gets a Sentry http.server transaction; the mysql - // queries run inside it, so their db spans attach to that transaction. The - // channels were injected at build time by `@sentry/bun/plugin`, and the Bun - // SDK subscribes to them by default. - const transactionPromise = waitForTransaction('bun-mysql', event => { - return ( - event?.contexts?.trace?.op === 'http.server' && - (event.request?.url ?? '').includes('/test-mysql') && - (event.spans?.some(span => span.op === 'db') ?? false) - ); - }); + // Each incoming request gets a Sentry http.server segment span; the mysql + // queries run inside it, so their db spans join that trace. The channels + // were injected at build time by `@sentry/bun/plugin`, and the Bun SDK + // subscribes to them by default. + const spansPromise = collectStreamedSpans( + 'bun-mysql', + spans => spans.some(isTestMysqlSegment) && spans.some(span => getSpanOp(span) === 'db'), + ); const res = await fetch(`${baseURL}/test-mysql`); expect(res.status).toBe(200); await res.json(); - const transaction = await transactionPromise; - const dbSpans = transaction.spans!.filter(span => span.op === 'db'); + const spans = await spansPromise; + const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); - const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution'); expect(firstQuery).toBeDefined(); - expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql'); - expect(firstQuery!.data?.['db.system.name']).toBe('mysql'); - expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution'); - expect(firstQuery!.data?.['server.port']).toBe(3306); - expect(firstQuery!.data?.['db.user']).toBe('root'); + // With span streaming, db span names are the low-cardinality query summary, not the raw SQL + expect(firstQuery!.name).toBe('SELECT'); + expect(firstQuery!.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.db.mysql', type: 'string' }, + 'db.system.name': { value: 'mysql', type: 'string' }, + 'db.query.text': { value: 'SELECT 1 + 1 AS solution', type: 'string' }, + 'server.port': { value: 3306, type: 'integer' }, + 'db.user': { value: 'root', type: 'string' }, + }); }); -test('a nested query lands on the same transaction (async context restored)', async ({ baseURL }) => { +test('a nested query lands on the same trace (async context restored)', async ({ baseURL }) => { // The second query runs inside the first query's callback — i.e. across - // mysql's async socket-callback dispatch. Both spans appearing on the SAME - // http.server transaction proves the channel subscriber restored the parent + // mysql's async socket-callback dispatch. Both db spans being children of the + // SAME http.server segment proves the channel subscriber restored the parent // span across that async boundary (otherwise the nested query would start its - // own trace and never join this transaction). - const transactionPromise = waitForTransaction('bun-mysql', event => { - return ( - event?.contexts?.trace?.op === 'http.server' && - (event.request?.url ?? '').includes('/test-mysql') && - (event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 - ); - }); + // own trace and never join this one). + const spansPromise = collectStreamedSpans( + 'bun-mysql', + spans => spans.some(isTestMysqlSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, + ); const res = await fetch(`${baseURL}/test-mysql`); expect(res.status).toBe(200); await res.json(); - const transaction = await transactionPromise; - const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description); - expect(descriptions).toContain('SELECT 1 + 1 AS solution'); - expect(descriptions).toContain('SELECT NOW()'); + const spans = await spansPromise; + const segment = spans.find(isTestMysqlSegment)!; + const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); + + const queries = dbSpans.map(span => span.attributes['db.query.text']?.value); + expect(queries).toContain('SELECT 1 + 1 AS solution'); + expect(queries).toContain('SELECT NOW()'); + expect(dbSpans.every(span => span.parent_span_id === segment.span_id)).toBe(true); }); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/.gitignore b/dev-packages/e2e-tests/test-applications/elysia-bun-static/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json b/dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json new file mode 100644 index 000000000000..e9bcf1b0a53d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/package.json @@ -0,0 +1,25 @@ +{ + "name": "elysia-bun-static-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/elysia": "file:../../packed/sentry-elysia-packed.tgz", + "elysia": "^1.4.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "bun-types": "^1.2.9" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/elysia-bun-static/playwright.config.mjs new file mode 100644 index 000000000000..44d3b834833d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'bun src/app.ts', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/src/app.ts b/dev-packages/e2e-tests/test-applications/elysia-bun-static/src/app.ts new file mode 100644 index 000000000000..19675299b24b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/src/app.ts @@ -0,0 +1,143 @@ +import * as Sentry from '@sentry/elysia'; +import { Elysia } from 'elysia'; + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +const app = Sentry.withElysia(new Elysia()); + +// Simple success route +app.get('/test-success', () => ({ version: 'v1' })); + +// Parameterized route +app.get('/test-param/:param', ({ params }) => ({ paramWas: params.param })); + +// Multiple params +app.get('/test-multi-param/:param1/:param2', ({ params }) => ({ + param1: params.param1, + param2: params.param2, +})); + +// Route that throws an error (will be caught by onError) +app.get('/test-exception/:id', ({ params }) => { + throw new Error(`This is an exception with id ${params.id}`); +}); + +// Route with a custom span +app.get('/test-transaction', () => { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + return { status: 'ok' }; +}); + +// Route with specific middleware via .guard or .use +app.group('/with-middleware', app => + app + .onBeforeHandle(() => { + // This is a route-specific middleware + }) + .get('/test', () => ({ middleware: true })), +); + +// Error with specific status code +app.post('/test-post-error', () => { + throw new Error('Post error'); +}); + +// Route that returns a non-500 error +app.get('/test-4xx', ({ set }) => { + set.status = 400; + return { error: 'Bad Request' }; +}); + +// Error that reaches the error handler with status still set to 200 (unusual, should still be captured) +app.get('/test-error-with-200-status', ({ set }) => { + set.status = 200; + throw new Error('Error with 200 status'); +}); + +// POST route that echoes body +app.post('/test-post', ({ body }) => ({ status: 'ok', body })); + +// Route that returns inbound headers (for propagation tests) +app.get('/test-inbound-headers/:id', ({ params, request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, id: params.id }; +}); + +// Outgoing fetch propagation +app.get('/test-outgoing-fetch/:id', async ({ params }) => { + const id = params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (allowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-allowed', async () => { + const response = await fetch(`http://localhost:3040/external-allowed`); + const data = await response.json(); + return data; +}); + +// Outgoing fetch to external (disallowed by tracePropagationTargets) +app.get('/test-outgoing-fetch-external-disallowed', async () => { + const response = await fetch(`http://localhost:3040/external-disallowed`); + const data = await response.json(); + return data; +}); + +// Route that throws a string (not an Error object) +app.get('/test-string-error', () => { + // eslint-disable-next-line no-throw-literal + throw 'String error message'; +}); + +// Route for concurrent isolation tests — returns scope data in response +app.get('/test-isolation/:userId', async ({ params }) => { + Sentry.setUser({ id: params.userId }); + Sentry.setTag('user_id', params.userId); + + // Simulate async work to increase overlap between concurrent requests + await new Promise(resolve => setTimeout(resolve, 200)); + + return { + userId: params.userId, + isolationScopeUserId: Sentry.getIsolationScope().getUser()?.id, + isolationScopeTag: Sentry.getIsolationScope().getScopeData().tags?.user_id, + }; +}); + +// Flush route for waiting on events +app.get('/flush', async () => { + await Sentry.flush(); + return { ok: true }; +}); + +app.listen(3030, () => { + console.log('Elysia app listening on port 3030'); +}); + +// Second app for external propagation tests +const app2 = new Elysia(); + +app2.get('/external-allowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-allowed' }; +}); + +app2.get('/external-disallowed', ({ request }) => { + const headers = Object.fromEntries(request.headers.entries()); + return { headers, route: '/external-disallowed' }; +}); + +app2.listen(3040, () => { + console.log('External app listening on port 3040'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/elysia-bun-static/start-event-proxy.mjs new file mode 100644 index 000000000000..7a5a51881da5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'elysia-bun-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/errors.test.ts new file mode 100644 index 000000000000..ae4f762db3ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/errors.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Captures an error thrown in a route handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await request.get(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual( + expect.objectContaining({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Error event includes request metadata', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await request.get(`${baseURL}/test-exception/456`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/test-exception/456'), + headers: expect.any(Object), + }), + ); +}); + +test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => { + const transactionPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-4xx'; + }); + + const response = await request.get(`${baseURL}/test-4xx`); + // Wait for the transaction to ensure the request was processed + await transactionPromise; + + expect(response.status()).toBe(400); +}); + +test('Captures errors even when status is <= 299 in error handler', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Error with 200 status'; + }); + + await request.get(`${baseURL}/test-error-with-200-status`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error with 200 status'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures POST route errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Post error'; + }); + + await request.post(`${baseURL}/test-post-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Post error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.elysia.on_error', + handled: false, + }); +}); + +test('Captures thrown string errors', async ({ baseURL, request }) => { + const errorEventPromise = waitForError('elysia-bun-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'String error message'; + }); + + await request.get(`${baseURL}/test-string-error`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('String error message'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + type: 'auto.http.elysia.on_error', + handled: false, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/isolation.test.ts new file mode 100644 index 000000000000..3bdc1cc2e99d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/isolation.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +// The Elysia integration currently does not fork isolation scopes per request, +// so `setUser`/`setTag` on the isolation scope leaks between concurrent requests. +// This test documents the expected behavior once per-request isolation is implemented. +test.fixme('Concurrent requests have isolated scope data', async ({ baseURL }) => { + // Fire 3 concurrent requests with different user IDs + const [response1, response2, response3] = await Promise.all([ + fetch(`${baseURL}/test-isolation/user-1`), + fetch(`${baseURL}/test-isolation/user-2`), + fetch(`${baseURL}/test-isolation/user-3`), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + // Each response should have its own user ID — no leaking between requests + expect(data1.userId).toBe('user-1'); + expect(data1.isolationScopeUserId).toBe('user-1'); + expect(data1.isolationScopeTag).toBe('user-1'); + + expect(data2.userId).toBe('user-2'); + expect(data2.isolationScopeUserId).toBe('user-2'); + expect(data2.isolationScopeTag).toBe('user-2'); + + expect(data3.userId).toBe('user-3'); + expect(data3.isolationScopeUserId).toBe('user-3'); + expect(data3.isolationScopeTag).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/propagation.test.ts new file mode 100644 index 000000000000..106c9d9c7ade --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/propagation.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Includes sentry-trace and baggage in response headers', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/test-success`); + + const sentryTrace = response.headers.get('sentry-trace'); + const baggage = response.headers.get('baggage'); + + expect(sentryTrace).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-[01]/); + expect(baggage).toContain('sentry-environment=qa'); + expect(baggage).toContain('sentry-trace_id='); +}); + +// Bun's native fetch does not emit undici diagnostics channels, so the +// nativeNodeFetchIntegration cannot see these requests. `@sentry/bun`'s +// `fetchIntegration` instead patches the global `fetch` (like Cloudflare), which +// is what creates the spans and injects sentry-trace/baggage headers below. + +test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = randomUUID(); + + const inboundTransactionPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-inbound-headers/:id' + ); + }); + + const outboundTransactionPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch/:id' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + expect(traceId).toEqual(expect.any(String)); + + // Verify sentry-trace header was propagated to the inbound request + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toMatch(new RegExp(`^${traceId}-[a-f0-9]{16}-1$`)); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + // Both transactions should share the same trace ID + expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId); +}); + +test('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-allowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + + expect(traceId).toEqual(expect.any(String)); + + expect(data.route).toBe('/external-allowed'); + expect(data.headers?.['sentry-trace']).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-1/); + expect(data.headers?.baggage).toBeDefined(); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-outgoing-fetch-external-disallowed' + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); + const data = await response.json(); + + await inboundTransactionPromise; + + expect(data.route).toBe('/external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/transactions.test.ts new file mode 100644 index 000000000000..24ac6b53a34a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tests/transactions.test.ts @@ -0,0 +1,216 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a successful route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-success', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'http.server', + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }), + ); +}); + +test('Sends a transaction with parameterized route name', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-param/:param' + ); + }); + + await request.get(`${baseURL}/test-param/123`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-param/:param'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction with multiple parameterized segments', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-multi-param/:param1/:param2' + ); + }); + + await request.get(`${baseURL}/test-multi-param/foo/bar`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-multi-param/:param1/:param2'); + expect(transactionEvent.transaction_info?.source).toBe('route'); +}); + +test('Sends a transaction for an errored route', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-exception/:id' + ); + }); + + await request.get(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); + +test('Includes manually started spans with parent-child relationship', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await request.get(`${baseURL}/test-transaction`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + const testSpan = spans.find(span => span.description === 'test-span'); + const childSpan = spans.find(span => span.description === 'child-span'); + + expect(testSpan).toEqual( + expect.objectContaining({ + description: 'test-span', + origin: 'manual', + }), + ); + + expect(childSpan).toEqual( + expect.objectContaining({ + description: 'child-span', + origin: 'manual', + parent_span_id: testSpan?.span_id, + }), + ); +}); + +test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' + ); + }); + + await request.get(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // Elysia should produce lifecycle spans enriched with sentry attributes + const elysiaSpans = spans.filter(span => span.origin === 'auto.http.elysia'); + expect(elysiaSpans.length).toBeGreaterThan(0); + + // The Handle span should be present as a request handler + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'Handle', + op: 'handler', + origin: 'auto.http.elysia', + }), + ); +}); + +test('Names anonymous handler spans as "anonymous" instead of ""', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + // Use a route with middleware so there are child handler spans + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // No spans should exist — we name them 'anonymous' instead + const unknownSpans = spans.filter(span => span.description === ''); + expect(unknownSpans).toHaveLength(0); + + // Anonymous handler spans should be named 'anonymous' + const anonymousSpans = spans.filter(span => span.description === 'anonymous' && span.origin === 'auto.http.elysia'); + expect(anonymousSpans.length).toBeGreaterThan(0); + + // Named Elysia lifecycle spans should still be present + expect(spans.filter(span => span.origin === 'auto.http.elysia').length).toBeGreaterThan(0); +}); + +test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /with-middleware/test' + ); + }); + + await request.get(`${baseURL}/with-middleware/test`); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // BeforeHandle span should be present from the route-specific middleware + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'BeforeHandle', + op: 'middleware', + origin: 'auto.http.elysia', + }), + ); +}); + +test('Captures request metadata for POST requests', async ({ baseURL, request }) => { + const transactionEventPromise = waitForTransaction('elysia-bun-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post' + ); + }); + + const response = await request.post(`${baseURL}/test-post`, { + data: { foo: 'bar', other: 1 }, + headers: { 'Content-Type': 'application/json' }, + }); + const resBody = await response.json(); + + expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/test-post'), + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tsconfig.json new file mode 100644 index 000000000000..869427e44d4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-bun-static/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts index 19675299b24b..db6a5fff8bd1 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/src/app.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/elysia'; import { Elysia } from 'elysia'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts index 8bd544e6f9c2..5e587e28fd41 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Captures an error thrown in a route handler', async ({ baseURL, request }) => { const errorEventPromise = waitForError('elysia-bun', event => { @@ -47,13 +47,13 @@ test('Error event includes request metadata', async ({ baseURL, request }) => { }); test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => { - const transactionPromise = waitForTransaction('elysia-bun', transactionEvent => { - return transactionEvent?.transaction === 'GET /test-4xx'; + const spanPromise = waitForStreamedSpan('elysia-bun', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET /test-4xx' && span.is_segment; }); const response = await request.get(`${baseURL}/test-4xx`); - // Wait for the transaction to ensure the request was processed - await transactionPromise; + // Wait for the segment span to ensure the request was processed + await spanPromise; expect(response.status()).toBe(400); }); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts index c3c3bdd5b2bc..601153c43e18 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Includes sentry-trace and baggage in response headers', async ({ baseURL }) => { const response = await fetch(`${baseURL}/test-success`); @@ -21,27 +21,21 @@ test('Includes sentry-trace and baggage in response headers', async ({ baseURL } test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const id = randomUUID(); - const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-inbound-headers/:id' - ); + const inboundSpanPromise = waitForStreamedSpan('elysia-bun', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET /test-inbound-headers/:id' && span.is_segment; }); - const outboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-outgoing-fetch/:id' - ); + const outboundSpanPromise = waitForStreamedSpan('elysia-bun', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET /test-outgoing-fetch/:id' && span.is_segment; }); const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - const outboundTransaction = await outboundTransactionPromise; + const inboundSpan = await inboundSpanPromise; + const outboundSpan = await outboundSpanPromise; - const traceId = outboundTransaction?.contexts?.trace?.trace_id; + const traceId = outboundSpan.trace_id; expect(traceId).toEqual(expect.any(String)); // Verify sentry-trace header was propagated to the inbound request @@ -60,23 +54,22 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { ]), ); - // Both transactions should share the same trace ID - expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId); + // Both segment spans should share the same trace ID + expect(inboundSpan.trace_id).toBe(traceId); }); test('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + const inboundSpanPromise = waitForStreamedSpan('elysia-bun', span => { return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-outgoing-fetch-external-allowed' + getSpanOp(span) === 'http.server' && span.name === 'GET /test-outgoing-fetch-external-allowed' && span.is_segment ); }); const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const inboundSpan = await inboundSpanPromise; + const traceId = inboundSpan.trace_id; expect(traceId).toEqual(expect.any(String)); @@ -95,17 +88,18 @@ test('Propagates trace for outgoing fetch to external allowed URL', async ({ bas }); test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => { + const inboundSpanPromise = waitForStreamedSpan('elysia-bun', span => { return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-outgoing-fetch-external-disallowed' + getSpanOp(span) === 'http.server' && + span.name === 'GET /test-outgoing-fetch-external-disallowed' && + span.is_segment ); }); const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); const data = await response.json(); - await inboundTransactionPromise; + await inboundSpanPromise; expect(data.route).toBe('/external-disallowed'); expect(data.headers?.['sentry-trace']).toBeUndefined(); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts index 28ce37877f38..431ce4233d89 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/transactions.test.ts @@ -1,197 +1,181 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Sends a transaction for a successful route', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' - ); +test('Sends a segment span for a successful route', async ({ baseURL, request }) => { + const spanPromise = waitForStreamedSpan('elysia-bun', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET /test-success' && span.is_segment; }); await request.get(`${baseURL}/test-success`); - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'GET /test-success', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); + const span = await spanPromise; - expect(transactionEvent.contexts?.trace).toEqual( + expect(span).toEqual( expect.objectContaining({ - op: 'http.server', + name: 'GET /test-success', + is_segment: true, status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), }), ); + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.elysia', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.route': { value: '/test-success', type: 'string' }, + }); }); -test('Sends a transaction with parameterized route name', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-param/:param' - ); +test('Sends a segment span with parameterized route name', async ({ baseURL, request }) => { + const spanPromise = waitForStreamedSpan('elysia-bun', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET /test-param/:param' && span.is_segment; }); await request.get(`${baseURL}/test-param/123`); - const transactionEvent = await transactionEventPromise; + const span = await spanPromise; - expect(transactionEvent.transaction).toBe('GET /test-param/:param'); - expect(transactionEvent.transaction_info?.source).toBe('route'); + expect(span.name).toBe('GET /test-param/:param'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Sends a transaction with multiple parameterized segments', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { +test('Sends a segment span with multiple parameterized segments', async ({ baseURL, request }) => { + const spanPromise = waitForStreamedSpan('elysia-bun', span => { return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-multi-param/:param1/:param2' + getSpanOp(span) === 'http.server' && span.name === 'GET /test-multi-param/:param1/:param2' && span.is_segment ); }); await request.get(`${baseURL}/test-multi-param/foo/bar`); - const transactionEvent = await transactionEventPromise; + const span = await spanPromise; - expect(transactionEvent.transaction).toBe('GET /test-multi-param/:param1/:param2'); - expect(transactionEvent.transaction_info?.source).toBe('route'); + expect(span.name).toBe('GET /test-multi-param/:param1/:param2'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Sends a transaction for an errored route', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-exception/:id' - ); +test('Sends a segment span for an errored route', async ({ baseURL, request }) => { + const spanPromise = waitForStreamedSpan('elysia-bun', span => { + return getSpanOp(span) === 'http.server' && span.name === 'GET /test-exception/:id' && span.is_segment; }); await request.get(`${baseURL}/test-exception/777`); - const transactionEvent = await transactionEventPromise; + const span = await spanPromise; - expect(transactionEvent.transaction).toBe('GET /test-exception/:id'); - expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); + expect(span.name).toBe('GET /test-exception/:id'); + expect(span.status).toBe('error'); }); test('Includes manually started spans with parent-child relationship', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-transaction' - ); - }); + const spansPromise = collectStreamedSpans('elysia-bun', spans => + spans.some(span => span.name === 'GET /test-transaction' && span.is_segment), + ); await request.get(`${baseURL}/test-transaction`); - const transactionEvent = await transactionEventPromise; - const spans = transactionEvent.spans || []; + const spans = await spansPromise; - const testSpan = spans.find(span => span.description === 'test-span'); - const childSpan = spans.find(span => span.description === 'child-span'); + const testSpan = spans.find(span => span.name === 'test-span'); + const childSpan = spans.find(span => span.name === 'child-span'); expect(testSpan).toEqual( expect.objectContaining({ - description: 'test-span', - origin: 'manual', + name: 'test-span', + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'manual', type: 'string' }, + }), }), ); expect(childSpan).toEqual( expect.objectContaining({ - description: 'child-span', - origin: 'manual', + name: 'child-span', parent_span_id: testSpan?.span_id, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'manual', type: 'string' }, + }), }), ); }); test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success' - ); - }); + const spansPromise = collectStreamedSpans('elysia-bun', spans => + spans.some(span => span.name === 'GET /test-success' && span.is_segment), + ); await request.get(`${baseURL}/test-success`); - const transactionEvent = await transactionEventPromise; - const spans = transactionEvent.spans || []; + const spans = await spansPromise; // Elysia should produce lifecycle spans enriched with sentry attributes - const elysiaSpans = spans.filter(span => span.origin === 'auto.http.elysia'); + const elysiaSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.http.elysia'); expect(elysiaSpans.length).toBeGreaterThan(0); - // The Handle span should be present as a request handler + // With span streaming, request handler spans are named after their route expect(spans).toContainEqual( expect.objectContaining({ - description: 'Handle', - op: 'handler', - origin: 'auto.http.elysia', + name: '/test-success', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'handler', type: 'string' }, + 'sentry.origin': { value: 'auto.http.elysia', type: 'string' }, + 'http.route': { value: '/test-success', type: 'string' }, + }), }), ); }); -test('Names anonymous handler spans as "anonymous" instead of ""', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /with-middleware/test' - ); - }); +test('Names handler spans after the route instead of ""', async ({ baseURL, request }) => { + const spansPromise = collectStreamedSpans('elysia-bun', spans => + spans.some(span => span.name === 'GET /with-middleware/test' && span.is_segment), + ); // Use a route with middleware so there are child handler spans await request.get(`${baseURL}/with-middleware/test`); - const transactionEvent = await transactionEventPromise; - const spans = transactionEvent.spans || []; + const spans = await spansPromise; - // No spans should exist — we name them 'anonymous' instead - const unknownSpans = spans.filter(span => span.description === ''); + // No spans should exist + const unknownSpans = spans.filter(span => span.name === ''); expect(unknownSpans).toHaveLength(0); - // Anonymous handler spans should be named 'anonymous' - const anonymousSpans = spans.filter(span => span.description === 'anonymous' && span.origin === 'auto.http.elysia'); - expect(anonymousSpans.length).toBeGreaterThan(0); + // Handler spans are named after the route, so the (anonymous) handler name never becomes a span name + const handlerSpans = spans.filter(span => getSpanOp(span) === 'handler'); + expect(handlerSpans.length).toBeGreaterThan(0); + expect(handlerSpans.every(span => span.name === '/with-middleware/test')).toBe(true); // Named Elysia lifecycle spans should still be present - expect(spans.filter(span => span.origin === 'auto.http.elysia').length).toBeGreaterThan(0); + expect(spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.http.elysia').length).toBeGreaterThan( + 0, + ); }); test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /with-middleware/test' - ); - }); + const spansPromise = collectStreamedSpans('elysia-bun', spans => + spans.some(span => span.name === 'GET /with-middleware/test' && span.is_segment), + ); await request.get(`${baseURL}/with-middleware/test`); - const transactionEvent = await transactionEventPromise; - const spans = transactionEvent.spans || []; + const spans = await spansPromise; // BeforeHandle span should be present from the route-specific middleware expect(spans).toContainEqual( expect.objectContaining({ - description: 'BeforeHandle', - op: 'middleware', - origin: 'auto.http.elysia', + name: 'BeforeHandle', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.http.elysia', type: 'string' }, + }), }), ); }); test('Captures request metadata for POST requests', async ({ baseURL, request }) => { - const transactionEventPromise = waitForTransaction('elysia-bun', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post' - ); + const spanPromise = waitForStreamedSpan('elysia-bun', span => { + return getSpanOp(span) === 'http.server' && span.name === 'POST /test-post' && span.is_segment; }); const response = await request.post(`${baseURL}/test-post`, { @@ -202,15 +186,10 @@ test('Captures request metadata for POST requests', async ({ baseURL, request }) expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); - const transactionEvent = await transactionEventPromise; + const span = await spanPromise; - expect(transactionEvent.request).toEqual( - expect.objectContaining({ - method: 'POST', - url: expect.stringContaining('/test-post'), - headers: expect.objectContaining({ - 'content-type': 'application/json', - }), - }), - ); + expect(span.attributes).toMatchObject({ + 'url.path': { value: '/test-post', type: 'string' }, + 'url.full': { value: expect.stringContaining('/test-post'), type: 'string' }, + }); }); From 60c4a6a3f5340a27adb882e5f3f2533df824bef3 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 3 Sep 2026 14:50:39 +0200 Subject: [PATCH 2/2] test(bun): Capture an exception instead of a message in the fetch breadcrumb suite Co-Authored-By: Claude Fable 5.1 --- dev-packages/bun-integration-tests/suites/fetch/index.ts | 4 ++-- dev-packages/bun-integration-tests/suites/fetch/test.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dev-packages/bun-integration-tests/suites/fetch/index.ts b/dev-packages/bun-integration-tests/suites/fetch/index.ts index 2f3780bda0a0..f378ee6c786d 100644 --- a/dev-packages/bun-integration-tests/suites/fetch/index.ts +++ b/dev-packages/bun-integration-tests/suites/fetch/index.ts @@ -38,9 +38,9 @@ const server = Bun.serve({ return Response.json(data); } - if (url.pathname === '/outgoing-fetch-message') { + if (url.pathname === '/outgoing-fetch-error') { await fetch(`${targetUrl}/allowed`); - Sentry.captureMessage('fetch done'); + Sentry.captureException(new Error('fetch done')); return new Response('OK'); } diff --git a/dev-packages/bun-integration-tests/suites/fetch/test.ts b/dev-packages/bun-integration-tests/suites/fetch/test.ts index 7aa91ec30cd7..e503f78519a5 100644 --- a/dev-packages/bun-integration-tests/suites/fetch/test.ts +++ b/dev-packages/bun-integration-tests/suites/fetch/test.ts @@ -1,4 +1,4 @@ -import type { Envelope, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; +import type { Envelope, Event, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; @@ -64,14 +64,15 @@ it('does not propagate headers to outgoing fetch requests outside tracePropagati }); it('records a breadcrumb for outgoing fetch requests', async ({ signal }) => { - // Streamed spans carry no breadcrumbs, so the breadcrumb is asserted on a message + // Streamed spans carry no breadcrumbs, so the breadcrumb is asserted on an error // captured right after the fetch instead. const runner = createRunner(__dirname) .expect(envelope => { const [, envelopeItems] = envelope; - const [itemHeader, event] = envelopeItems[0] as [{ type: string }, { breadcrumbs?: unknown[] }]; + const [itemHeader, event] = envelopeItems[0] as [{ type: string }, Event]; expect(itemHeader.type).toBe('event'); + expect(event.exception?.values?.[0]?.value).toBe('fetch done'); expect(event.breadcrumbs).toContainEqual( expect.objectContaining({ @@ -88,6 +89,6 @@ it('records a breadcrumb for outgoing fetch requests', async ({ signal }) => { .ignore('span') .start(signal); - await runner.makeRequest('get', '/outgoing-fetch-message'); + await runner.makeRequest('get', '/outgoing-fetch-error'); await runner.completed(); });