diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts b/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts index 70caa0413157..375ca9a29c6d 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts @@ -3,7 +3,6 @@ import { Elysia } from 'elysia'; import { node } from '@elysiajs/node'; 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-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts index aac83b65e703..258d7abc5adf 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Captures an error thrown in a route handler', async ({ baseURL, request }) => { const errorEventPromise = waitForError('elysia-node', event => { @@ -47,13 +47,14 @@ test('Error event includes request metadata', async ({ baseURL, request }) => { }); test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => { - const transactionPromise = waitForTransaction('elysia-node', transactionEvent => { - return transactionEvent?.transaction === 'GET /test-4xx'; - }); + const segmentPromise = waitForStreamedSpan( + 'elysia-node', + segment => segment.is_segment && segment.name === 'GET /test-4xx', + ); const response = await request.get(`${baseURL}/test-4xx`); - // Wait for the transaction to ensure the request was processed - await transactionPromise; + // Wait for the segment to ensure the request was processed + await segmentPromise; expect(response.status()).toBe(400); }); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts index 14bdbdf7ca6b..c4f7e3b77b97 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-node/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 { waitForStreamedSpan, getSpanOp } 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,23 @@ test('Includes sentry-trace and baggage in response headers', async ({ baseURL } test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const id = randomUUID(); - const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-inbound-headers/:id' - ); + const inboundSegmentPromise = waitForStreamedSpan('elysia-node', segmentEvent => { + if (!segmentEvent.is_segment) return false; + return getSpanOp(segmentEvent!) === 'http.server' && segmentEvent.name === 'GET /test-inbound-headers/:id'; }); - const outboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-outgoing-fetch/:id' - ); + const outboundSegmentPromise = waitForStreamedSpan('elysia-node', segmentEvent => { + if (!segmentEvent.is_segment) return false; + return getSpanOp(segmentEvent!) === 'http.server' && segmentEvent.name === '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 inboundSegment = await inboundSegmentPromise; + const outboundSegment = await outboundSegmentPromise; - const traceId = outboundTransaction?.contexts?.trace?.trace_id; + const traceId = outboundSegment?.trace_id; expect(traceId).toEqual(expect.any(String)); // Verify sentry-trace header was propagated to the inbound request @@ -61,22 +57,22 @@ test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) = ); // Both transactions should share the same trace ID - expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId); + expect(inboundSegment?.trace_id).toBe(traceId); }); test.fixme('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { + const inboundSegmentPromise = waitForStreamedSpan('elysia-node', segmentEvent => { + if (!segmentEvent.is_segment) return false; return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-outgoing-fetch-external-allowed' + getSpanOp(segmentEvent!) === 'http.server' && segmentEvent.name === '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; + const inboundSegment = await inboundSegmentPromise; + const traceId = inboundSegment?.trace_id; expect(traceId).toEqual(expect.any(String)); @@ -95,17 +91,18 @@ test.fixme('Propagates trace for outgoing fetch to external allowed URL', async }); test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('elysia-node', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-outgoing-fetch-external-disallowed' - ); - }); + const inboundSegmentPromise = waitForStreamedSpan( + 'elysia-node', + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === 'GET /test-outgoing-fetch-external-disallowed', + ); const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); const data = await response.json(); - await inboundTransactionPromise; + await inboundSegmentPromise; expect(data.route).toBe('/external-disallowed'); expect(data.headers?.['sentry-trace']).toBeUndefined(); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/spans.test.ts new file mode 100644 index 000000000000..300c2dcab0b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/elysia-node/tests/spans.test.ts @@ -0,0 +1,221 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +test('Sends a span for a successful route', async ({ baseURL, request }) => { + const segmentEventPromise = waitForStreamedSpan( + 'elysia-node', + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-success', + ); + + await request.get(`${baseURL}/test-success`); + + const segmentEvent = await segmentEventPromise; + + expect(segmentEvent).toEqual( + expect.objectContaining({ + name: 'GET /test-success', + is_segment: true, + attributes: expect.objectContaining({ 'sentry.segment.name.source': { value: 'route', type: 'string' } }), + }), + ); + + expect(segmentEvent).toEqual( + expect.objectContaining({ + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + attributes: expect.objectContaining({ 'sentry.op': { value: 'http.server', type: 'string' } }), + }), + ); +}); + +test('Sends a span with parameterized route name', async ({ baseURL, request }) => { + const segmentEventPromise = waitForStreamedSpan( + 'elysia-node', + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-param/:param', + ); + + await request.get(`${baseURL}/test-param/123`); + + const segmentEvent = await segmentEventPromise; + + expect(segmentEvent.name).toBe('GET /test-param/:param'); + expect(segmentEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); +}); + +test('Sends a span with multiple parameterized segments', async ({ baseURL, request }) => { + const segmentEventPromise = waitForStreamedSpan( + 'elysia-node', + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === 'GET /test-multi-param/:param1/:param2', + ); + + await request.get(`${baseURL}/test-multi-param/foo/bar`); + + const segmentEvent = await segmentEventPromise; + + expect(segmentEvent.name).toBe('GET /test-multi-param/:param1/:param2'); + expect(segmentEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); +}); + +test('Sends a span for an errored route', async ({ baseURL, request }) => { + const segmentEventPromise = waitForStreamedSpan( + 'elysia-node', + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-exception/:id', + ); + + await request.get(`${baseURL}/test-exception/777`); + + const segmentEvent = await segmentEventPromise; + + expect(segmentEvent.name).toBe('GET /test-exception/:id'); + expect(segmentEvent?.status).toBe('error'); +}); + +test('Includes manually started spans with parent-child relationship', async ({ baseURL, request }) => { + const segmentEventPromise = collectStreamedSpansUntilSegment( + 'elysia-node', + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-transaction', + ); + + await request.get(`${baseURL}/test-transaction`); + + const segmentEventSpans = await segmentEventPromise; + const segmentEvent = segmentEventSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-transaction', + )!; + const spans = segmentEventSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id, + ); + + const testSpan = spans.find(span => span.name === 'test-span'); + const childSpan = spans.find(span => span.name === 'child-span'); + + expect(testSpan).toEqual( + expect.objectContaining({ + name: 'test-span', + attributes: expect.objectContaining({ 'sentry.origin': { value: 'manual', type: 'string' } }), + }), + ); + + expect(childSpan).toEqual( + expect.objectContaining({ + 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 segmentEventPromise = collectStreamedSpansUntilSegment( + 'elysia-node', + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-success', + ); + + await request.get(`${baseURL}/test-success`); + + const segmentEventSpans = await segmentEventPromise; + const segmentEvent = segmentEventSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-success', + )!; + const spans = segmentEventSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id, + ); + + // Elysia should produce lifecycle spans enriched with sentry attributes + const elysiaSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.http.elysia'); + expect(elysiaSpans.length).toBeGreaterThan(0); + + expect(spans.filter(span => span.name === '/test-success')).toEqual([ + expect.objectContaining({ + name: '/test-success', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'handler', type: 'string' }, + 'sentry.origin': { value: 'auto.http.elysia', type: 'string' }, + }), + }), + ]); +}); + +test('Names middleware "anonymous" and handlers after their route', async ({ baseURL, request }) => { + const segmentEventPromise = collectStreamedSpansUntilSegment( + 'elysia-node', + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /with-middleware/test', + ); + + await request.get(`${baseURL}/with-middleware/test`); + + const segmentEventSpans = await segmentEventPromise; + const segmentEvent = segmentEventSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /with-middleware/test', + )!; + const spans = segmentEventSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id, + ); + + const unknownSpans = spans.filter(span => span.name === ''); + expect(unknownSpans).toHaveLength(0); + + const elysiaSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.http.elysia'); + const anonymousMiddlewareSpans = elysiaSpans.filter( + span => getSpanOp(span) === 'middleware' && span.name === 'anonymous', + ); + expect(anonymousMiddlewareSpans).toHaveLength(1); + + const handlerSpans = elysiaSpans.filter(span => getSpanOp(span) === 'handler'); + expect(handlerSpans.map(span => span.name)).toEqual(['/with-middleware/test']); +}); + +test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => { + const segmentEventPromise = collectStreamedSpansUntilSegment( + 'elysia-node', + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /with-middleware/test', + ); + + await request.get(`${baseURL}/with-middleware/test`); + + const segmentEventSpans = await segmentEventPromise; + const segmentEvent = segmentEventSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /with-middleware/test', + )!; + const spans = segmentEventSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id, + ); + + // BeforeHandle span should be present from the route-specific middleware + expect(spans.filter(span => span.name === 'BeforeHandle')).toEqual([ + expect.objectContaining({ + 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 segmentEventPromise = waitForStreamedSpan( + 'elysia-node', + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === '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 segmentEvent = await segmentEventPromise; + + expect(segmentEvent.attributes['http.request.method']?.value).toEqual('POST'); + expect(segmentEvent.attributes['url.full']?.value).toEqual(expect.stringContaining('/test-post')); + expect(segmentEvent.attributes['http.request.header.content_type']?.value).toEqual('application/json'); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts deleted file mode 100644 index 71cda9825466..000000000000 --- a/dev-packages/e2e-tests/test-applications/elysia-node/tests/transactions.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -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-node', 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-node', 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-node', 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-node', 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-node', 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-node', 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-node', 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-node', 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-node', 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', - }), - }), - ); -});