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 @@
- Fetch Server Data
+ Fetch Server API Error
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 @@
- Fetch Server Data
+ Fetch Server API Error
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 @@
- Fetch Server Data
+ Fetch Server API Error
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 @@
- Fetch Server Data
+ Fetch Server API Error
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 @@
- Fetch Server Data
+ Fetch Server API Error
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