diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 919d74e78542..a1eb1b559b9c 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -133,3 +133,24 @@ A standardized frontend test application has the following features: ### Standardized Backend Test Apps TBD + +### Standardized Frontend-to-Backend Test Apps + +A standardized Meta-Framework test application has the following features: + +- Has a parameterized backend API route `/user/:id` that returns a JSON object with the user ID. +- Has a parameterized frontend page (can be SSR) `/user/:id` that fetches the user data on the client-side from the API route and displays it. + +This setup creates the scenario where the frontend page loads, and then immediately makes an API request to the backend API. + +The following test cases for connected tracing should be implemented in the test app: + +- Capturing a distributed page load trace when a page is loaded + - The HTML meta-tag should include the Sentry trace data and baggage + - The server root span should be the parent of the client pageload span + - All routes (server and client) should be parameterized, e.g. `/user/5` should be captured as `/user/:id` route +- Capturing a distributed trace when requesting the API from the client-side + - There should be three transactions involved: the client pageload, the server "pageload", and the server API request + - The client pageload should include an `http.client` span that is the parent of the server API request span + - All three transactions and the `http.client` span should share the same `trace_id` + - All `transaction` names and the `span` description should be parameterized, e.g. `/user/5` should be captured as `/user/:id` route diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/fetch-server-error.vue index 8cb2a9997e58..0e9aeb34b4fc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/fetch-server-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/fetch-server-error.vue @@ -1,13 +1,13 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/test-param/[param].vue index e83392b37b5c..019404aaf460 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/test-param/[param].vue @@ -1,12 +1,12 @@ + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/user/[userId].ts new file mode 100644 index 000000000000..eb268d2f3e13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts index b781642c2b4f..b18da9ba0a3b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts @@ -8,7 +8,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); - await page.getByText('Fetch Server Data', { exact: true }).click(); + await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -26,7 +26,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts index 0bc6ffa80b73..216c3b27ec62 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts @@ -10,7 +10,7 @@ test.describe('distributed tracing', () => { }); const serverTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { - return txnEvent.transaction.includes('GET /test-param/'); + return txnEvent.transaction?.includes('GET /test-param/') || false; }); const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ @@ -47,8 +47,8 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) - transaction_info: { source: 'url' }, + transaction: `GET /test-param/:param`, + transaction_info: { source: 'route' }, type: 'transaction', contexts: { trace: { @@ -66,4 +66,91 @@ test.describe('distributed tracing', () => { expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); + + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) + transaction_info: { source: 'url' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/fetch-server-error.vue index 8cb2a9997e58..0e9aeb34b4fc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/fetch-server-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/fetch-server-error.vue @@ -1,13 +1,13 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/test-param/[param].vue index e83392b37b5c..019404aaf460 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/pages/test-param/[param].vue @@ -1,12 +1,12 @@ + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/server/api/user/[userId].ts new file mode 100644 index 000000000000..eb268d2f3e13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/server/api/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts index 8f20aa938893..40904b51b993 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts @@ -8,7 +8,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); - await page.getByText('Fetch Server Data', { exact: true }).click(); + await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -26,7 +26,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts index cb86df11fe84..214bbdbb5f11 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts @@ -47,8 +47,8 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) - transaction_info: { source: 'url' }, + transaction: `GET /test-param/:param`, + transaction_info: { source: 'route' }, type: 'transaction', contexts: { trace: { @@ -66,4 +66,91 @@ test.describe('distributed tracing', () => { expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); + + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-3-min', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-3-min', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-3-min', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) + transaction_info: { source: 'url' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/fetch-server-error.vue index 8cb2a9997e58..0e9aeb34b4fc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/fetch-server-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/fetch-server-error.vue @@ -1,13 +1,13 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/test-param/[param].vue index e83392b37b5c..019404aaf460 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/test-param/[param].vue @@ -1,12 +1,12 @@ + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/user/[userId].ts new file mode 100644 index 000000000000..eb268d2f3e13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts index 053ec5b6ab67..551a33ee1fc1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts @@ -12,7 +12,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); - await page.getByText('Fetch Server Data', { exact: true }).click(); + await page.getByText('Fetch Server API Error', { exact: true }).click(); const transactionEvent = await transactionEventPromise; const error = await errorPromise; @@ -40,7 +40,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); - await page.getByText('Fetch Server Data', { exact: true }).click(); + await page.getByText('Fetch Server API Error', { exact: true }).click(); const transactionEvent = await transactionEventPromise; const error = await errorPromise; @@ -57,7 +57,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts index 69c4bd2833c4..9199daa8f074 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts @@ -66,4 +66,91 @@ test.describe('distributed tracing', () => { expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); + + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-3-top-level-import', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-3-top-level-import', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-3-top-level-import', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) + transaction_info: { source: 'url' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/fetch-server-error.vue index 8cb2a9997e58..0e9aeb34b4fc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/fetch-server-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/fetch-server-error.vue @@ -1,13 +1,13 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue index e83392b37b5c..019404aaf460 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue @@ -1,12 +1,12 @@ + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/user/[userId].ts new file mode 100644 index 000000000000..eb268d2f3e13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts index d1556d511bf0..b62654b60c47 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts @@ -8,7 +8,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); - await page.getByText('Fetch Server Data', { exact: true }).click(); + await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -26,7 +26,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts index 523ece4cc085..0be48299c0a5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts @@ -23,7 +23,7 @@ test.describe('distributed tracing', () => { const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); - expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); // URL-encoded for 'GET /test-param/:param' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -47,8 +47,8 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) - transaction_info: { source: 'url' }, + transaction: `GET /test-param/:param`, + transaction_info: { source: 'route' }, type: 'transaction', contexts: { trace: { @@ -66,4 +66,91 @@ test.describe('distributed tracing', () => { expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); + + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) + transaction_info: { source: 'url' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-error.vue index 8cb2a9997e58..0e9aeb34b4fc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-error.vue @@ -1,13 +1,13 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue index e83392b37b5c..019404aaf460 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue @@ -1,12 +1,12 @@ + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/user/[userId].ts new file mode 100644 index 000000000000..eb268d2f3e13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 396870d19925..1d593cb09caf 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -8,7 +8,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/fetch-server-error`); - await page.getByText('Fetch Server Data', { exact: true }).click(); + await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; @@ -26,7 +26,7 @@ test.describe('server-side errors', async () => { }); await page.goto(`/test-param/1234`); - await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index 505a912c95d5..5fa12df6b121 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -10,7 +10,7 @@ test.describe('distributed tracing', () => { }); const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction.includes('GET /test-param/'); + return txnEvent.transaction?.includes('GET /test-param/') ?? false; }); const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ @@ -47,7 +47,7 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -66,4 +66,91 @@ test.describe('distributed tracing', () => { expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); + + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro) + transaction_info: { source: 'url' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); }); diff --git a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts new file mode 100644 index 000000000000..4dd1db22bf8e --- /dev/null +++ b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts @@ -0,0 +1,49 @@ +import { getActiveSpan, getCurrentScope, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import type { H3Event } from 'h3'; + +/** + * Update the root span (transaction) name for routes with parameters based on the matched route. + */ +export function updateRouteBeforeResponse(event: H3Event): void { + if (event.context.matchedRoute) { + const matchedRoutePath = event.context.matchedRoute.path; + + // If the matched route path is defined and differs from the event's path, it indicates a parametrized route + // Example: Matched route is "/users/:id" and the event's path is "/users/123", + if (matchedRoutePath && matchedRoutePath !== event._path) { + if (matchedRoutePath === '/**') { + // todo: support parametrized SSR pageload spans + // If page is server-side rendered, the whole path gets transformed to `/**` (Example : `/users/123` becomes `/**` instead of `/users/:id`). + return; // Skip if the matched route is a catch-all route. + } + + const method = event._method || 'GET'; + + const parametrizedTransactionName = `${method.toUpperCase()} ${matchedRoutePath}`; + getCurrentScope().setTransactionName(parametrizedTransactionName); + + const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + if (rootSpan) { + rootSpan.updateName(parametrizedTransactionName); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': matchedRoutePath, + }); + + const params = event.context?.params || null; + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + // Based on this convention: https://getsentry.github.io/sentry-conventions/generated/attributes/url.html#urlpathparameterkey + rootSpan.setAttribute(`url.path.parameter.${key}`, String(value)); + }); + } + + logger.log(`Updated transaction name for parametrized route: ${parametrizedTransactionName}`); + } + } + } + } +} diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 9d10e9bd86d0..96fa59a4c643 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -6,6 +6,7 @@ import type { H3Event } from 'h3'; import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; interface CfEventType { @@ -139,6 +140,8 @@ export const sentryCloudflareNitroPlugin = }, }); + nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); + // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { const storedTraceData = event?.context?.cf ? traceDataMap.get(event.context.cf) : undefined; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index baf9f2029051..0f13fbec0fd3 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -5,11 +5,14 @@ import { type EventHandler } from 'h3'; import { defineNitroPlugin } from 'nitropack/runtime'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags, flushIfServerless } from '../utils'; export default defineNitroPlugin(nitroApp => { nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); + nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); + nitroApp.hooks.hook('error', sentryCaptureErrorHook); // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context