diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 660f3fe17e46..1df50881932d 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -22,3 +22,5 @@ updates:
prefix: feat
prefix-development: feat
include: scope
+ exclude-paths:
+ - 'dev-packages/e2e-tests/test-applications/'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1989cd3a1f95..3a1100ba7ad0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,35 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 10.17.0
+
+### Important Changes
+
+- **feat(nuxt): Implement server middleware instrumentation ([#17796](https://github.com/getsentry/sentry-javascript/pull/17796))**
+
+ This release introduces instrumentation for Nuxt middleware, ensuring that all middleware handlers are automatically wrapped with tracing and error reporting functionality.
+
+- **fix(aws-serverless): Take `http_proxy` into account when choosing
+ `useLayerExtension` default ([#17817](https://github.com/getsentry/sentry-javascript/pull/17817))**
+
+ The default setting for `useLayerExtension` now considers the `http_proxy` environment variable.
+ When `http_proxy` is set, `useLayerExtension` will be off by default.
+ If you use a `http_proxy` but would still like to make use of the Sentry Lambda extension, exempt `localhost` in a `no_proxy` environment variable.
+
+### Other Changes
+
+- feat(node): Split up http integration into composable parts ([#17524](https://github.com/getsentry/sentry-javascript/pull/17524))
+- fix(core): Remove check and always respect ai.telemetry.functionId for Vercel AI gen spans ([#17811](https://github.com/getsentry/sentry-javascript/pull/17811))
+- doc(core): Fix outdated JSDoc in `beforeSendSpan` ([#17815](https://github.com/getsentry/sentry-javascript/pull/17815))
+
+
+ Internal Changes
+
+- ci: Do not run dependabot on e2e test applications ([#17813](https://github.com/getsentry/sentry-javascript/pull/17813))
+- docs: Reword changelog for google gen ai integration ([#17805](https://github.com/getsentry/sentry-javascript/pull/17805))
+
+
+
## 10.16.0
- feat(logs): Add internal `replay_is_buffering` flag ([#17752](https://github.com/getsentry/sentry-javascript/pull/17752))
@@ -81,7 +110,7 @@ Work in this release was contributed by @Karibash. Thank you for your contributi
- **feat(cloudflare,vercel-edge): Add support for Google Gen AI instrumentation ([#17723](https://github.com/getsentry/sentry-javascript/pull/17723))**
- The SDK now supports manually instrumenting Google's Generative AI operations in Cloudflare Workers and Vercel Edge Runtime environments, providing insights into your AI operations. You can use `const wrappedClient = Sentry.instrumentGoogleGenAIClient(genAiClient)` to get an instrumented client.
+ The SDK now supports manually instrumenting Google's Gen AI operations in Cloudflare Workers and Vercel Edge Runtime environments, providing insights into your AI operations. You can use `const wrappedClient = Sentry.instrumentGoogleGenAIClient(genAiClient)` to get an instrumented client.
### Other Changes
@@ -119,9 +148,9 @@ Work in this release was contributed by @Karibash. Thank you for your contributi
Note that if `Sentry.reportPageLoaded()` is not called within 30 seconds of the initial pageload (or whatever value the `finalTimeout` option is set to), the pageload span will be ended automatically.
-- **feat(core,node): Add instrumentation for `GoogleGenerativeAI` ([#17625](https://github.com/getsentry/sentry-javascript/pull/17625))**
+- **feat(core,node): Add instrumentation for `GoogleGenAI` ([#17625](https://github.com/getsentry/sentry-javascript/pull/17625))**
- The SDK now automatically instruments the `@google/generative-ai` package to provide insights into your AI operations.
+ The SDK now automatically instruments the `@google/genai` package to provide insights into your AI operations.
- **feat(nextjs): Promote `useRunAfterProductionCompileHook` to non-experimental build option ([#17721](https://github.com/getsentry/sentry-javascript/pull/17721))**
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/middleware-test.ts
new file mode 100644
index 000000000000..8973690e6adb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/middleware-test.ts
@@ -0,0 +1,15 @@
+import { defineEventHandler, getHeader } from '#imports';
+
+export default defineEventHandler(async event => {
+ // Simple API endpoint that will trigger all server middleware
+ return {
+ message: 'Server middleware test endpoint',
+ path: event.path,
+ method: event.method,
+ headers: {
+ 'x-first-middleware': getHeader(event, 'x-first-middleware'),
+ 'x-second-middleware': getHeader(event, 'x-second-middleware'),
+ 'x-auth-middleware': getHeader(event, 'x-auth-middleware'),
+ },
+ };
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/01.first.ts
new file mode 100644
index 000000000000..b146c42e3483
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/01.first.ts
@@ -0,0 +1,6 @@
+import { defineEventHandler, setHeader } from '#imports';
+
+export default defineEventHandler(async event => {
+ // Set a header to indicate this middleware ran
+ setHeader(event, 'x-first-middleware', 'executed');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/02.second.ts
new file mode 100644
index 000000000000..3b665d48fc5a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/02.second.ts
@@ -0,0 +1,7 @@
+import { eventHandler, setHeader } from '#imports';
+
+// tests out the eventHandler alias
+export default eventHandler(async event => {
+ // Set a header to indicate this middleware ran
+ setHeader(event, 'x-second-middleware', 'executed');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/03.auth.ts
new file mode 100644
index 000000000000..6dcd9a075589
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/03.auth.ts
@@ -0,0 +1,12 @@
+import { defineEventHandler, setHeader, getQuery } from '#imports';
+
+export default defineEventHandler(async event => {
+ // Check if we should throw an error
+ const query = getQuery(event);
+ if (query.throwError === 'true') {
+ throw new Error('Auth middleware error');
+ }
+
+ // Set a header to indicate this middleware ran
+ setHeader(event, 'x-auth-middleware', 'executed');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/04.hooks.ts
new file mode 100644
index 000000000000..1f9cf40a1c02
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/04.hooks.ts
@@ -0,0 +1,36 @@
+import { defineEventHandler, setHeader, getQuery } from '#imports';
+
+export default defineEventHandler({
+ onRequest: async event => {
+ // Set a header to indicate the onRequest hook ran
+ setHeader(event, 'x-hooks-onrequest', 'executed');
+
+ // Check if we should throw an error in onRequest
+ const query = getQuery(event);
+ if (query.throwOnRequestError === 'true') {
+ throw new Error('OnRequest hook error');
+ }
+ },
+
+ handler: async event => {
+ // Set a header to indicate the main handler ran
+ setHeader(event, 'x-hooks-handler', 'executed');
+
+ // Check if we should throw an error in handler
+ const query = getQuery(event);
+ if (query.throwHandlerError === 'true') {
+ throw new Error('Handler error');
+ }
+ },
+
+ onBeforeResponse: async (event, response) => {
+ // Set a header to indicate the onBeforeResponse hook ran
+ setHeader(event, 'x-hooks-onbeforeresponse', 'executed');
+
+ // Check if we should throw an error in onBeforeResponse
+ const query = getQuery(event);
+ if (query.throwOnBeforeResponseError === 'true') {
+ throw new Error('OnBeforeResponse hook error');
+ }
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/05.array-hooks.ts
new file mode 100644
index 000000000000..cc815bfb2fbf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/05.array-hooks.ts
@@ -0,0 +1,47 @@
+import { defineEventHandler, setHeader, getQuery } from '#imports';
+
+export default defineEventHandler({
+ // Array of onRequest handlers
+ onRequest: [
+ async event => {
+ setHeader(event, 'x-array-onrequest-0', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnRequest0Error === 'true') {
+ throw new Error('OnRequest[0] hook error');
+ }
+ },
+ async event => {
+ setHeader(event, 'x-array-onrequest-1', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnRequest1Error === 'true') {
+ throw new Error('OnRequest[1] hook error');
+ }
+ },
+ ],
+
+ handler: async event => {
+ setHeader(event, 'x-array-handler', 'executed');
+ },
+
+ // Array of onBeforeResponse handlers
+ onBeforeResponse: [
+ async (event, response) => {
+ setHeader(event, 'x-array-onbeforeresponse-0', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnBeforeResponse0Error === 'true') {
+ throw new Error('OnBeforeResponse[0] hook error');
+ }
+ },
+ async (event, response) => {
+ setHeader(event, 'x-array-onbeforeresponse-1', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnBeforeResponse1Error === 'true') {
+ throw new Error('OnBeforeResponse[1] hook error');
+ }
+ },
+ ],
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts
new file mode 100644
index 000000000000..e9debf8496c2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts
@@ -0,0 +1,332 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction, waitForError } from '@sentry-internal/test-utils';
+
+test.describe('Server Middleware Instrumentation', () => {
+ test('should create separate spans for each server middleware', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ // Make request to the API endpoint that will trigger all server middleware
+ const response = await request.get('/api/middleware-test');
+ expect(response.status()).toBe(200);
+
+ const responseData = await response.json();
+ expect(responseData.message).toBe('Server middleware test endpoint');
+
+ const serverTxnEvent = await serverTxnEventPromise;
+
+ // Verify that we have spans for each middleware
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse)
+ expect(middlewareSpans).toHaveLength(11);
+
+ // Check for specific middleware spans
+ const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first');
+ const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second');
+ const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth');
+ const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks');
+ const arrayHooksHandlerSpan = middlewareSpans.find(
+ span => span.data?.['nuxt.middleware.name'] === '05.array-hooks',
+ );
+
+ expect(firstMiddlewareSpan).toBeDefined();
+ expect(secondMiddlewareSpan).toBeDefined();
+ expect(authMiddlewareSpan).toBeDefined();
+ expect(hooksOnRequestSpan).toBeDefined();
+ expect(arrayHooksHandlerSpan).toBeDefined();
+
+ // Verify each span has the correct attributes
+ [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => {
+ expect(span).toEqual(
+ expect.objectContaining({
+ op: 'middleware.nuxt',
+ data: expect.objectContaining({
+ 'sentry.op': 'middleware.nuxt',
+ 'sentry.origin': 'auto.middleware.nuxt',
+ 'sentry.source': 'custom',
+ 'http.request.method': 'GET',
+ 'http.route': '/api/middleware-test',
+ }),
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ }),
+ );
+ });
+
+ // Verify spans have different span IDs (each middleware gets its own span)
+ const spanIds = middlewareSpans.map(span => span.span_id);
+ const uniqueSpanIds = new Set(spanIds);
+ // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse)
+ expect(uniqueSpanIds.size).toBe(11);
+
+ // Verify spans share the same trace ID
+ const traceIds = middlewareSpans.map(span => span.trace_id);
+ const uniqueTraceIds = new Set(traceIds);
+ expect(uniqueTraceIds.size).toBe(1);
+ });
+
+ test('middleware spans should have proper parent-child relationship', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ await request.get('/api/middleware-test');
+ const serverTxnEvent = await serverTxnEventPromise;
+
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // All middleware spans should be children of the main transaction
+ middlewareSpans.forEach(span => {
+ expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id);
+ });
+ });
+
+ test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-3', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error';
+ });
+
+ // Make request with query param to trigger error in auth middleware
+ const response = await request.get('/api/middleware-test?throwError=true');
+
+ // The request should fail due to the middleware error
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the auth middleware span
+ const authMiddlewareSpan = serverTxnEvent.spans?.find(
+ span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '03.auth',
+ );
+
+ expect(authMiddlewareSpan).toBeDefined();
+
+ // Verify the span has error status
+ expect(authMiddlewareSpan?.status).toBe('internal_error');
+
+ // Verify the error event is associated with the correct transaction
+ expect(errorEvent.transaction).toContain('GET /api/middleware-test');
+
+ // Verify the error has the correct mechanism
+ expect(errorEvent.exception?.values?.[0]).toEqual(
+ expect.objectContaining({
+ value: 'Auth middleware error',
+ type: 'Error',
+ mechanism: expect.objectContaining({
+ handled: false,
+ type: 'auto.middleware.nuxt',
+ }),
+ }),
+ );
+ });
+
+ test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ // Make request to trigger middleware with hooks
+ const response = await request.get('/api/middleware-test');
+ expect(response.status()).toBe(200);
+
+ const serverTxnEvent = await serverTxnEventPromise;
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // Find spans for the hooks middleware
+ const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks');
+
+ // Should have spans for onRequest, handler, and onBeforeResponse
+ expect(hooksSpans).toHaveLength(3);
+
+ // Find specific hook spans
+ const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest');
+ const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler');
+ const onBeforeResponseSpan = hooksSpans.find(
+ span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse',
+ );
+
+ expect(onRequestSpan).toBeDefined();
+ expect(handlerSpan).toBeDefined();
+ expect(onBeforeResponseSpan).toBeDefined();
+
+ // Verify span names include hook types
+ expect(onRequestSpan?.description).toBe('04.hooks.onRequest');
+ expect(handlerSpan?.description).toBe('04.hooks');
+ expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse');
+
+ // Verify all spans have correct middleware name (without hook suffix)
+ [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => {
+ expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks');
+ });
+
+ // Verify hook-specific attributes
+ expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest');
+ expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler');
+ expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse');
+
+ // Verify no index attributes for single hooks
+ expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ });
+
+ test('should create spans with index attributes for array hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ // Make request to trigger middleware with array hooks
+ const response = await request.get('/api/middleware-test');
+ expect(response.status()).toBe(200);
+
+ const serverTxnEvent = await serverTxnEventPromise;
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // Find spans for the array hooks middleware
+ const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks');
+
+ // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans
+ expect(arrayHooksSpans).toHaveLength(5);
+
+ // Find onRequest array spans
+ const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest');
+ expect(onRequestSpans).toHaveLength(2);
+
+ // Find onBeforeResponse array spans
+ const onBeforeResponseSpans = arrayHooksSpans.filter(
+ span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse',
+ );
+ expect(onBeforeResponseSpans).toHaveLength(2);
+
+ // Find handler span
+ const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler');
+ expect(handlerSpan).toBeDefined();
+
+ // Verify index attributes for onRequest array
+ const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0);
+ const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1);
+
+ expect(onRequest0Span).toBeDefined();
+ expect(onRequest1Span).toBeDefined();
+
+ // Verify index attributes for onBeforeResponse array
+ const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0);
+ const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1);
+
+ expect(onBeforeResponse0Span).toBeDefined();
+ expect(onBeforeResponse1Span).toBeDefined();
+
+ // Verify span names for array handlers
+ expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest');
+ expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest');
+ expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse');
+ expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse');
+
+ // Verify handler has no index
+ expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ });
+
+ test('should handle errors in onRequest hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-3', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error';
+ });
+
+ // Make request with query param to trigger error in onRequest
+ const response = await request.get('/api/middleware-test?throwOnRequestError=true');
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the onRequest span that should have error status
+ const onRequestSpan = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '04.hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onRequest',
+ );
+
+ expect(onRequestSpan).toBeDefined();
+ expect(onRequestSpan?.status).toBe('internal_error');
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error');
+ });
+
+ test('should handle errors in onBeforeResponse hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-3', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error';
+ });
+
+ // Make request with query param to trigger error in onBeforeResponse
+ const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true');
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the onBeforeResponse span that should have error status
+ const onBeforeResponseSpan = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '04.hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse',
+ );
+
+ expect(onBeforeResponseSpan).toBeDefined();
+ expect(onBeforeResponseSpan?.status).toBe('internal_error');
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error');
+ });
+
+ test('should handle errors in array hooks with proper index attribution', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-3', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error';
+ });
+
+ // Make request with query param to trigger error in second onRequest handler
+ const response = await request.get('/api/middleware-test?throwOnRequest1Error=true');
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the second onRequest span that should have error status
+ const onRequest1Span = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '05.array-hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onRequest' &&
+ span.data?.['nuxt.middleware.hook.index'] === 1,
+ );
+
+ expect(onRequest1Span).toBeDefined();
+ expect(onRequest1Span?.status).toBe('internal_error');
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error');
+
+ // Verify the first onRequest handler still executed successfully
+ const onRequest0Span = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '05.array-hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onRequest' &&
+ span.data?.['nuxt.middleware.hook.index'] === 0,
+ );
+
+ expect(onRequest0Span).toBeDefined();
+ expect(onRequest0Span?.status).not.toBe('internal_error');
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/middleware-test.ts
new file mode 100644
index 000000000000..8973690e6adb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/middleware-test.ts
@@ -0,0 +1,15 @@
+import { defineEventHandler, getHeader } from '#imports';
+
+export default defineEventHandler(async event => {
+ // Simple API endpoint that will trigger all server middleware
+ return {
+ message: 'Server middleware test endpoint',
+ path: event.path,
+ method: event.method,
+ headers: {
+ 'x-first-middleware': getHeader(event, 'x-first-middleware'),
+ 'x-second-middleware': getHeader(event, 'x-second-middleware'),
+ 'x-auth-middleware': getHeader(event, 'x-auth-middleware'),
+ },
+ };
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/01.first.ts
new file mode 100644
index 000000000000..b146c42e3483
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/01.first.ts
@@ -0,0 +1,6 @@
+import { defineEventHandler, setHeader } from '#imports';
+
+export default defineEventHandler(async event => {
+ // Set a header to indicate this middleware ran
+ setHeader(event, 'x-first-middleware', 'executed');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/02.second.ts
new file mode 100644
index 000000000000..3b665d48fc5a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/02.second.ts
@@ -0,0 +1,7 @@
+import { eventHandler, setHeader } from '#imports';
+
+// tests out the eventHandler alias
+export default eventHandler(async event => {
+ // Set a header to indicate this middleware ran
+ setHeader(event, 'x-second-middleware', 'executed');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts
new file mode 100644
index 000000000000..6dcd9a075589
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts
@@ -0,0 +1,12 @@
+import { defineEventHandler, setHeader, getQuery } from '#imports';
+
+export default defineEventHandler(async event => {
+ // Check if we should throw an error
+ const query = getQuery(event);
+ if (query.throwError === 'true') {
+ throw new Error('Auth middleware error');
+ }
+
+ // Set a header to indicate this middleware ran
+ setHeader(event, 'x-auth-middleware', 'executed');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/04.hooks.ts
new file mode 100644
index 000000000000..1f9cf40a1c02
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/04.hooks.ts
@@ -0,0 +1,36 @@
+import { defineEventHandler, setHeader, getQuery } from '#imports';
+
+export default defineEventHandler({
+ onRequest: async event => {
+ // Set a header to indicate the onRequest hook ran
+ setHeader(event, 'x-hooks-onrequest', 'executed');
+
+ // Check if we should throw an error in onRequest
+ const query = getQuery(event);
+ if (query.throwOnRequestError === 'true') {
+ throw new Error('OnRequest hook error');
+ }
+ },
+
+ handler: async event => {
+ // Set a header to indicate the main handler ran
+ setHeader(event, 'x-hooks-handler', 'executed');
+
+ // Check if we should throw an error in handler
+ const query = getQuery(event);
+ if (query.throwHandlerError === 'true') {
+ throw new Error('Handler error');
+ }
+ },
+
+ onBeforeResponse: async (event, response) => {
+ // Set a header to indicate the onBeforeResponse hook ran
+ setHeader(event, 'x-hooks-onbeforeresponse', 'executed');
+
+ // Check if we should throw an error in onBeforeResponse
+ const query = getQuery(event);
+ if (query.throwOnBeforeResponseError === 'true') {
+ throw new Error('OnBeforeResponse hook error');
+ }
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/05.array-hooks.ts
new file mode 100644
index 000000000000..cc815bfb2fbf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/05.array-hooks.ts
@@ -0,0 +1,47 @@
+import { defineEventHandler, setHeader, getQuery } from '#imports';
+
+export default defineEventHandler({
+ // Array of onRequest handlers
+ onRequest: [
+ async event => {
+ setHeader(event, 'x-array-onrequest-0', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnRequest0Error === 'true') {
+ throw new Error('OnRequest[0] hook error');
+ }
+ },
+ async event => {
+ setHeader(event, 'x-array-onrequest-1', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnRequest1Error === 'true') {
+ throw new Error('OnRequest[1] hook error');
+ }
+ },
+ ],
+
+ handler: async event => {
+ setHeader(event, 'x-array-handler', 'executed');
+ },
+
+ // Array of onBeforeResponse handlers
+ onBeforeResponse: [
+ async (event, response) => {
+ setHeader(event, 'x-array-onbeforeresponse-0', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnBeforeResponse0Error === 'true') {
+ throw new Error('OnBeforeResponse[0] hook error');
+ }
+ },
+ async (event, response) => {
+ setHeader(event, 'x-array-onbeforeresponse-1', 'executed');
+
+ const query = getQuery(event);
+ if (query.throwOnBeforeResponse1Error === 'true') {
+ throw new Error('OnBeforeResponse[1] hook error');
+ }
+ },
+ ],
+});
diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts
new file mode 100644
index 000000000000..005330c01fee
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts
@@ -0,0 +1,332 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction, waitForError } from '@sentry-internal/test-utils';
+
+test.describe('Server Middleware Instrumentation', () => {
+ test('should create separate spans for each server middleware', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ // Make request to the API endpoint that will trigger all server middleware
+ const response = await request.get('/api/middleware-test');
+ expect(response.status()).toBe(200);
+
+ const responseData = await response.json();
+ expect(responseData.message).toBe('Server middleware test endpoint');
+
+ const serverTxnEvent = await serverTxnEventPromise;
+
+ // Verify that we have spans for each middleware
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse
+ expect(middlewareSpans).toHaveLength(11);
+
+ // Check for specific middleware spans
+ const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first');
+ const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second');
+ const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth');
+ const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks');
+ const arrayHooksHandlerSpan = middlewareSpans.find(
+ span => span.data?.['nuxt.middleware.name'] === '05.array-hooks',
+ );
+
+ expect(firstMiddlewareSpan).toBeDefined();
+ expect(secondMiddlewareSpan).toBeDefined();
+ expect(authMiddlewareSpan).toBeDefined();
+ expect(hooksOnRequestSpan).toBeDefined();
+ expect(arrayHooksHandlerSpan).toBeDefined();
+
+ // Verify each span has the correct attributes
+ [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => {
+ expect(span).toEqual(
+ expect.objectContaining({
+ op: 'middleware.nuxt',
+ data: expect.objectContaining({
+ 'sentry.op': 'middleware.nuxt',
+ 'sentry.origin': 'auto.middleware.nuxt',
+ 'sentry.source': 'custom',
+ 'http.request.method': 'GET',
+ 'http.route': '/api/middleware-test',
+ }),
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ }),
+ );
+ });
+
+ // Verify spans have different span IDs (each middleware gets its own span)
+ const spanIds = middlewareSpans.map(span => span.span_id);
+ const uniqueSpanIds = new Set(spanIds);
+ // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse)
+ expect(uniqueSpanIds.size).toBe(11);
+
+ // Verify spans share the same trace ID
+ const traceIds = middlewareSpans.map(span => span.trace_id);
+ const uniqueTraceIds = new Set(traceIds);
+ expect(uniqueTraceIds.size).toBe(1);
+ });
+
+ test('middleware spans should have proper parent-child relationship', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ await request.get('/api/middleware-test');
+ const serverTxnEvent = await serverTxnEventPromise;
+
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // All middleware spans should be children of the main transaction
+ middlewareSpans.forEach(span => {
+ expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id);
+ });
+ });
+
+ test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-4', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error';
+ });
+
+ // Make request with query param to trigger error in auth middleware
+ const response = await request.get('/api/middleware-test?throwError=true');
+
+ // The request should fail due to the middleware error
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the auth middleware span
+ const authMiddlewareSpan = serverTxnEvent.spans?.find(
+ span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '03.auth',
+ );
+
+ expect(authMiddlewareSpan).toBeDefined();
+
+ // Verify the span has error status
+ expect(authMiddlewareSpan?.status).toBe('internal_error');
+
+ // Verify the error event is associated with the correct transaction
+ expect(errorEvent.transaction).toContain('GET /api/middleware-test');
+
+ // Verify the error has the correct mechanism
+ expect(errorEvent.exception?.values?.[0]).toEqual(
+ expect.objectContaining({
+ value: 'Auth middleware error',
+ type: 'Error',
+ mechanism: expect.objectContaining({
+ handled: false,
+ type: 'auto.middleware.nuxt',
+ }),
+ }),
+ );
+ });
+
+ test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ // Make request to trigger middleware with hooks
+ const response = await request.get('/api/middleware-test');
+ expect(response.status()).toBe(200);
+
+ const serverTxnEvent = await serverTxnEventPromise;
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // Find spans for the hooks middleware
+ const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks');
+
+ // Should have spans for onRequest, handler, and onBeforeResponse
+ expect(hooksSpans).toHaveLength(3);
+
+ // Find specific hook spans
+ const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest');
+ const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler');
+ const onBeforeResponseSpan = hooksSpans.find(
+ span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse',
+ );
+
+ expect(onRequestSpan).toBeDefined();
+ expect(handlerSpan).toBeDefined();
+ expect(onBeforeResponseSpan).toBeDefined();
+
+ // Verify span names include hook types
+ expect(onRequestSpan?.description).toBe('04.hooks.onRequest');
+ expect(handlerSpan?.description).toBe('04.hooks');
+ expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse');
+
+ // Verify all spans have correct middleware name (without hook suffix)
+ [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => {
+ expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks');
+ });
+
+ // Verify hook-specific attributes
+ expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest');
+ expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler');
+ expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse');
+
+ // Verify no index attributes for single hooks
+ expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ });
+
+ test('should create spans with index attributes for array hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ // Make request to trigger middleware with array hooks
+ const response = await request.get('/api/middleware-test');
+ expect(response.status()).toBe(200);
+
+ const serverTxnEvent = await serverTxnEventPromise;
+ const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || [];
+
+ // Find spans for the array hooks middleware
+ const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks');
+
+ // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans
+ expect(arrayHooksSpans).toHaveLength(5);
+
+ // Find onRequest array spans
+ const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest');
+ expect(onRequestSpans).toHaveLength(2);
+
+ // Find onBeforeResponse array spans
+ const onBeforeResponseSpans = arrayHooksSpans.filter(
+ span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse',
+ );
+ expect(onBeforeResponseSpans).toHaveLength(2);
+
+ // Find handler span
+ const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler');
+ expect(handlerSpan).toBeDefined();
+
+ // Verify index attributes for onRequest array
+ const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0);
+ const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1);
+
+ expect(onRequest0Span).toBeDefined();
+ expect(onRequest1Span).toBeDefined();
+
+ // Verify index attributes for onBeforeResponse array
+ const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0);
+ const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1);
+
+ expect(onBeforeResponse0Span).toBeDefined();
+ expect(onBeforeResponse1Span).toBeDefined();
+
+ // Verify span names for array handlers
+ expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest');
+ expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest');
+ expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse');
+ expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse');
+
+ // Verify handler has no index
+ expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index');
+ });
+
+ test('should handle errors in onRequest hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-4', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error';
+ });
+
+ // Make request with query param to trigger error in onRequest
+ const response = await request.get('/api/middleware-test?throwOnRequestError=true');
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the onRequest span that should have error status
+ const onRequestSpan = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '04.hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onRequest',
+ );
+
+ expect(onRequestSpan).toBeDefined();
+ expect(onRequestSpan?.status).toBe('internal_error');
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error');
+ });
+
+ test('should handle errors in onBeforeResponse hooks', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-4', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error';
+ });
+
+ // Make request with query param to trigger error in onBeforeResponse
+ const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true');
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the onBeforeResponse span that should have error status
+ const onBeforeResponseSpan = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '04.hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse',
+ );
+
+ expect(onBeforeResponseSpan).toBeDefined();
+ expect(onBeforeResponseSpan?.status).toBe('internal_error');
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error');
+ });
+
+ test('should handle errors in array hooks with proper index attribution', async ({ request }) => {
+ const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => {
+ return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false;
+ });
+
+ const errorEventPromise = waitForError('nuxt-4', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error';
+ });
+
+ // Make request with query param to trigger error in second onRequest handler
+ const response = await request.get('/api/middleware-test?throwOnRequest1Error=true');
+ expect(response.status()).toBe(500);
+
+ const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]);
+
+ // Find the second onRequest span that should have error status
+ const onRequest1Span = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '05.array-hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onRequest' &&
+ span.data?.['nuxt.middleware.hook.index'] === 1,
+ );
+
+ expect(onRequest1Span).toBeDefined();
+ expect(onRequest1Span?.status).toBe('internal_error');
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error');
+
+ // Verify the first onRequest handler still executed successfully
+ const onRequest0Span = serverTxnEvent.spans?.find(
+ span =>
+ span.op === 'middleware.nuxt' &&
+ span.data?.['nuxt.middleware.name'] === '05.array-hooks' &&
+ span.data?.['nuxt.middleware.hook.name'] === 'onRequest' &&
+ span.data?.['nuxt.middleware.hook.index'] === 0,
+ );
+
+ expect(onRequest0Span).toBeDefined();
+ expect(onRequest0Span?.status).not.toBe('internal_error');
+ });
+});
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index d39cb5e4484d..790810e93797 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -66,6 +66,8 @@ export {
hapiIntegration,
honoIntegration,
httpIntegration,
+ httpServerIntegration,
+ httpServerSpansIntegration,
// eslint-disable-next-line deprecation/deprecation
inboundFiltersIntegration,
eventFiltersIntegration,
diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts
index 884747dcf72a..25dbb9416fe6 100644
--- a/packages/astro/src/server/sdk.ts
+++ b/packages/astro/src/server/sdk.ts
@@ -1,5 +1,5 @@
import { applySdkMetadata } from '@sentry/core';
-import type { NodeClient, NodeOptions } from '@sentry/node';
+import type { Event, NodeClient, NodeOptions } from '@sentry/node';
import { init as initNodeSdk } from '@sentry/node';
/**
@@ -13,5 +13,28 @@ export function init(options: NodeOptions): NodeClient | undefined {
applySdkMetadata(opts, 'astro', ['astro', 'node']);
- return initNodeSdk(opts);
+ const client = initNodeSdk(opts);
+
+ client?.addEventProcessor(
+ Object.assign(
+ (event: Event) => {
+ // For http.server spans that did not go though the astro middleware,
+ // we want to drop them
+ // this is the case with http.server spans of prerendered pages
+ // we do not care about those, as they are effectively static
+ if (
+ event.type === 'transaction' &&
+ event.contexts?.trace?.op === 'http.server' &&
+ event.contexts?.trace?.origin === 'auto.http.otel.http'
+ ) {
+ return null;
+ }
+
+ return event;
+ },
+ { id: 'AstroHttpEventProcessor' },
+ ),
+ );
+
+ return client;
}
diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts
index cfab7b72754b..f7e72ec908ae 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -50,6 +50,8 @@ export {
disableAnrDetectionForCallback,
consoleIntegration,
httpIntegration,
+ httpServerIntegration,
+ httpServerSpansIntegration,
nativeNodeFetchIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts
index 6640db8ec5fa..e19cc41baf46 100644
--- a/packages/aws-serverless/src/init.ts
+++ b/packages/aws-serverless/src/init.ts
@@ -5,6 +5,44 @@ import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegration
import { DEBUG_BUILD } from './debug-build';
import { awsIntegration } from './integration/aws';
import { awsLambdaIntegration } from './integration/awslambda';
+
+/**
+ * Checks if proxy environment variables would interfere with the layer extension.
+ * The layer extension uses localhost:9000, so we need to check if proxy settings would prevent this.
+ */
+function shouldDisableLayerExtensionForProxy(): boolean {
+ const { http_proxy, no_proxy } = process.env;
+
+ // If no http proxy is configured, no interference (https_proxy doesn't affect HTTP requests)
+ if (!http_proxy) {
+ return false;
+ }
+
+ // Check if localhost is exempted by no_proxy
+ if (no_proxy) {
+ const exemptions = no_proxy.split(',').map(exemption => exemption.trim().toLowerCase());
+
+ // Handle common localhost exemption patterns explicitly
+ // If localhost is exempted, requests to the layer extension will not be proxied
+ const localhostExemptions = ['*', 'localhost', '127.0.0.1', '::1'];
+ if (exemptions.some(exemption => localhostExemptions.includes(exemption))) {
+ return false;
+ }
+ }
+
+ // If http_proxy is set and no localhost exemption, it would interfere
+ // The layer extension uses HTTP to localhost:9000, so only http_proxy matters
+ if (http_proxy) {
+ DEBUG_BUILD &&
+ debug.log(
+ 'Disabling useLayerExtension due to http_proxy environment variable. Consider adding localhost to no_proxy to re-enable.',
+ );
+ return true;
+ }
+
+ return false;
+}
+
/**
* Get the default integrations for the AWSLambda SDK.
*/
@@ -28,9 +66,11 @@ export interface AwsServerlessOptions extends NodeOptions {
*/
export function init(options: AwsServerlessOptions = {}): NodeClient | undefined {
const sdkSource = getSDKSource();
+ const proxyWouldInterfere = shouldDisableLayerExtensionForProxy();
+
const opts = {
defaultIntegrations: getDefaultIntegrations(options),
- useLayerExtension: sdkSource === 'aws-lambda-layer' && !options.tunnel,
+ useLayerExtension: sdkSource === 'aws-lambda-layer' && !options.tunnel && !proxyWouldInterfere,
...options,
};
@@ -48,6 +88,11 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
} else {
DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.');
}
+ } else if (sdkSource === 'aws-lambda-layer' && proxyWouldInterfere) {
+ DEBUG_BUILD &&
+ debug.warn(
+ 'Sentry Lambda extension disabled due to proxy environment variables (http_proxy/https_proxy). Consider adding localhost to no_proxy to re-enable.',
+ );
}
applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], sdkSource);
diff --git a/packages/aws-serverless/test/init.test.ts b/packages/aws-serverless/test/init.test.ts
index 576257e3f3e4..e6a675ecc43f 100644
--- a/packages/aws-serverless/test/init.test.ts
+++ b/packages/aws-serverless/test/init.test.ts
@@ -1,6 +1,6 @@
import { getSDKSource } from '@sentry/core';
import { initWithoutDefaultIntegrations } from '@sentry/node';
-import { describe, expect, test, vi } from 'vitest';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { AwsServerlessOptions } from '../src/init';
import { init } from '../src/init';
@@ -18,6 +18,12 @@ const mockGetSDKSource = vi.mocked(getSDKSource);
const mockInitWithoutDefaultIntegrations = vi.mocked(initWithoutDefaultIntegrations);
describe('init', () => {
+ beforeEach(() => {
+ // Clean up environment variables between tests
+ delete process.env.http_proxy;
+ delete process.env.no_proxy;
+ });
+
describe('Lambda extension setup', () => {
test('should preserve user-provided tunnel option when Lambda extension is enabled', () => {
mockGetSDKSource.mockReturnValue('aws-lambda-layer');
@@ -128,4 +134,256 @@ describe('init', () => {
);
});
});
+
+ describe('proxy environment variables and layer extension', () => {
+ test('should enable useLayerExtension when no proxy env vars are set', () => {
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+
+ test('should disable useLayerExtension when http_proxy is set', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: false,
+ }),
+ );
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ tunnel: expect.any(String),
+ }),
+ );
+ });
+
+ describe('no_proxy patterns', () => {
+ test('should enable useLayerExtension when no_proxy=* (wildcard)', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = '*';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+
+ test('should enable useLayerExtension when no_proxy contains localhost', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = 'localhost';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+
+ test('should enable useLayerExtension when no_proxy contains 127.0.0.1', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = '127.0.0.1';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+
+ test('should enable useLayerExtension when no_proxy contains ::1', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = '::1';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+
+ test('should enable useLayerExtension when no_proxy contains localhost in a comma-separated list', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = 'example.com,localhost,other.com';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+
+ test('should disable useLayerExtension when no_proxy does not contain localhost patterns', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = 'example.com,other.com';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: false,
+ }),
+ );
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ tunnel: expect.any(String),
+ }),
+ );
+ });
+
+ test('should disable useLayerExtension when no_proxy contains host (no longer supported)', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = 'host';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: false,
+ }),
+ );
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ tunnel: expect.any(String),
+ }),
+ );
+ });
+
+ test('should handle case-insensitive no_proxy values', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = 'LOCALHOST';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+
+ test('should handle whitespace in no_proxy values', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = ' localhost , example.com ';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {};
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: true,
+ tunnel: 'http://localhost:9000/envelope',
+ }),
+ );
+ });
+ });
+
+ test('should respect explicit useLayerExtension=false even with no proxy interference', () => {
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {
+ useLayerExtension: false,
+ };
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: false,
+ }),
+ );
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ tunnel: expect.any(String),
+ }),
+ );
+ });
+
+ test('should respect explicit useLayerExtension=false even with proxy that would interfere', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {
+ useLayerExtension: false,
+ };
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: false,
+ }),
+ );
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ tunnel: expect.any(String),
+ }),
+ );
+ });
+
+ test('should respect explicit useLayerExtension=false even when no_proxy would enable it', () => {
+ process.env.http_proxy = 'http://proxy.example.com:8080';
+ process.env.no_proxy = 'localhost';
+ mockGetSDKSource.mockReturnValue('aws-lambda-layer');
+ const options: AwsServerlessOptions = {
+ useLayerExtension: false,
+ };
+
+ init(options);
+
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useLayerExtension: false,
+ }),
+ );
+ expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ tunnel: expect.any(String),
+ }),
+ );
+ });
+ });
});
diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts
index 68a1e2b6d6ff..2775cbc0624e 100644
--- a/packages/bun/src/index.ts
+++ b/packages/bun/src/index.ts
@@ -70,6 +70,8 @@ export {
disableAnrDetectionForCallback,
consoleIntegration,
httpIntegration,
+ httpServerIntegration,
+ httpServerSpansIntegration,
nativeNodeFetchIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 1de223b327c0..365b4f42d078 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -26,6 +26,7 @@ import type { Integration } from './types-hoist/integration';
import type { Log } from './types-hoist/log';
import type { ClientOptions } from './types-hoist/options';
import type { ParameterizedString } from './types-hoist/parameterize';
+import type { RequestEventData } from './types-hoist/request';
import type { SdkMetadata } from './types-hoist/sdkmetadata';
import type { Session, SessionAggregates } from './types-hoist/session';
import type { SeverityLevel } from './types-hoist/severity';
@@ -687,6 +688,17 @@ export abstract class Client {
*/
public on(hook: 'flushLogs', callback: () => void): () => void;
+ /**
+ * A hook that is called when a http server request is started.
+ * This hook is called after request isolation, but before the request is processed.
+ *
+ * @returns {() => void} A function that, when executed, removes the registered callback.
+ */
+ public on(
+ hook: 'httpServerRequest',
+ callback: (request: unknown, response: unknown, normalizedRequest: RequestEventData) => void,
+ ): () => void;
+
/**
* Register a hook on this client.
*/
@@ -875,6 +887,17 @@ export abstract class Client {
*/
public emit(hook: 'flushLogs'): void;
+ /**
+ * Emit a hook event for client when a http server request is started.
+ * This hook is called after request isolation, but before the request is processed.
+ */
+ public emit(
+ hook: 'httpServerRequest',
+ request: unknown,
+ response: unknown,
+ normalizedRequest: RequestEventData,
+ ): void;
+
/**
* Emit a hook that was previously registered via `on()`.
*/
diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts
index 92603bb0242d..43946c3d08e0 100644
--- a/packages/core/src/types-hoist/options.ts
+++ b/packages/core/src/types-hoist/options.ts
@@ -408,9 +408,6 @@ export interface ClientOptions {
expect(p5).toHaveBeenCalled();
expect(buffer.$.length).toEqual(5);
- const result = await buffer.drain(8);
+ const result = await buffer.drain(6);
expect(result).toEqual(false);
- // p5 is still in the buffer
- expect(buffer.$.length).toEqual(1);
+ // p5 & p4 are still in the buffer
+ // Leaving some wiggle room, possibly one or two items are still in the buffer
+ // to avoid flakiness
+ expect(buffer.$.length).toBeGreaterThanOrEqual(1);
// Now drain final item
const result2 = await buffer.drain();
diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts
index ac0f41079017..bab9dc3a1cbb 100644
--- a/packages/google-cloud-serverless/src/index.ts
+++ b/packages/google-cloud-serverless/src/index.ts
@@ -50,6 +50,8 @@ export {
disableAnrDetectionForCallback,
consoleIntegration,
httpIntegration,
+ httpServerIntegration,
+ httpServerSpansIntegration,
nativeNodeFetchIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts
index 87f96f09ab8e..e6cf209d23f6 100644
--- a/packages/node-core/src/index.ts
+++ b/packages/node-core/src/index.ts
@@ -1,6 +1,9 @@
import * as logger from './logs/exports';
export { httpIntegration } from './integrations/http';
+export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration';
+export { httpServerIntegration } from './integrations/http/httpServerIntegration';
+
export {
SentryHttpInstrumentation,
type SentryHttpInstrumentationOptions,
diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts
index 72aabfaa11e5..f8a10b0a1f8b 100644
--- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts
+++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts
@@ -2,16 +2,15 @@ import type { ChannelListener } from 'node:diagnostics_channel';
import { subscribe, unsubscribe } from 'node:diagnostics_channel';
import type * as http from 'node:http';
import type * as https from 'node:https';
-import type { Span } from '@opentelemetry/api';
import { context } from '@opentelemetry/api';
import { isTracingSuppressed } from '@opentelemetry/core';
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
+import type { Span } from '@sentry/core';
import { debug, LRUMap, SDK_VERSION } from '@sentry/core';
import { DEBUG_BUILD } from '../../debug-build';
import { getRequestUrl } from '../../utils/getRequestUrl';
import { INSTRUMENTATION_NAME } from './constants';
-import { instrumentServer } from './incoming-requests';
import {
addRequestBreadcrumb,
addTracePropagationHeadersToOutgoingRequest,
@@ -23,31 +22,12 @@ type Https = typeof https;
export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
/**
- * Whether breadcrumbs should be recorded for requests.
+ * Whether breadcrumbs should be recorded for outgoing requests.
*
* @default `true`
*/
breadcrumbs?: boolean;
- /**
- * Whether to create spans for requests or not.
- * As of now, creates spans for incoming requests, but not outgoing requests.
- *
- * @default `true`
- */
- spans?: boolean;
-
- /**
- * Whether to extract the trace ID from the `sentry-trace` header for incoming requests.
- * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled, ...)
- * then this instrumentation can take over.
- *
- * @deprecated This is always true and the option will be removed in the future.
- *
- * @default `true`
- */
- extractIncomingTraceFromHeader?: boolean;
-
/**
* Whether to propagate Sentry trace headers in outgoing requests.
* By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled)
@@ -57,20 +37,6 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
*/
propagateTraceInOutgoingRequests?: boolean;
- /**
- * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc.
- * This helps reduce noise in your transactions.
- *
- * @default `true`
- */
- ignoreStaticAssets?: boolean;
-
- /**
- * If true, do not generate spans for incoming requests at all.
- * This is used by Remix to avoid generating spans for incoming requests, as it generates its own spans.
- */
- disableIncomingRequestSpans?: boolean;
-
/**
* Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
* For the scope of this instrumentation, this callback only controls breadcrumb creation.
@@ -82,55 +48,51 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
*/
ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean;
+ // All options below do not do anything anymore in this instrumentation, and will be removed in the future.
+ // They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration.
+
/**
- * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`.
- *
- * @param urlPath Contains the URL path and query string (if any) of the incoming request.
- * @param request Contains the {@type IncomingMessage} object of the incoming request.
+ * @deprecated This no longer does anything.
*/
- ignoreSpansForIncomingRequests?: (urlPath: string, request: http.IncomingMessage) => boolean;
+ spans?: boolean;
/**
- * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`.
- * This can be useful for long running requests where the body is not needed and we want to avoid capturing it.
- *
- * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request.
- * @param request Contains the {@type RequestOptions} object used to make the incoming request.
+ * @depreacted This no longer does anything.
*/
- ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean;
+ extractIncomingTraceFromHeader?: boolean;
/**
- * A hook that can be used to mutate the span for incoming requests.
- * This is triggered after the span is created, but before it is recorded.
+ * @deprecated This no longer does anything.
*/
- incomingRequestSpanHook?: (span: Span, request: http.IncomingMessage, response: http.ServerResponse) => void;
+ ignoreStaticAssets?: boolean;
/**
- * Controls the maximum size of incoming HTTP request bodies attached to events.
- *
- * Available options:
- * - 'none': No request bodies will be attached
- * - 'small': Request bodies up to 1,000 bytes will be attached
- * - 'medium': Request bodies up to 10,000 bytes will be attached (default)
- * - 'always': Request bodies will always be attached
- *
- * Note that even with 'always' setting, bodies exceeding 1MB will never be attached
- * for performance and security reasons.
- *
- * @default 'medium'
+ * @deprecated This no longer does anything.
+ */
+ disableIncomingRequestSpans?: boolean;
+
+ /**
+ * @deprecated This no longer does anything.
+ */
+ ignoreSpansForIncomingRequests?: (urlPath: string, request: http.IncomingMessage) => boolean;
+
+ /**
+ * @deprecated This no longer does anything.
+ */
+ ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean;
+
+ /**
+ * @deprecated This no longer does anything.
*/
maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always';
/**
- * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry.
- * Read more about Release Health: https://docs.sentry.io/product/releases/health/
- *
- * Defaults to `true`.
+ * @deprecated This no longer does anything.
*/
trackIncomingRequestsAsSessions?: boolean;
/**
- * @deprecated This is deprecated in favor of `incomingRequestSpanHook`.
+ * @deprecated This no longer does anything.
*/
instrumentation?: {
requestHook?: (span: Span, req: http.ClientRequest | http.IncomingMessage) => void;
@@ -143,9 +105,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
};
/**
- * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate.
- *
- * Defaults to `60000` (60s).
+ * @deprecated This no longer does anything.
*/
sessionFlushingDelayMS?: number;
};
@@ -180,24 +140,6 @@ export class SentryHttpInstrumentation extends InstrumentationBase {
- const data = _data as { server: http.Server };
- instrumentServer(data.server, {
- // eslint-disable-next-line deprecation/deprecation
- instrumentation: this.getConfig().instrumentation,
- ignoreIncomingRequestBody: this.getConfig().ignoreIncomingRequestBody,
- ignoreSpansForIncomingRequests: this.getConfig().ignoreSpansForIncomingRequests,
- incomingRequestSpanHook: this.getConfig().incomingRequestSpanHook,
- maxIncomingRequestBodySize: this.getConfig().maxIncomingRequestBodySize,
- trackIncomingRequestsAsSessions: this.getConfig().trackIncomingRequestsAsSessions,
- sessionFlushingDelayMS: this.getConfig().sessionFlushingDelayMS ?? 60_000,
- ignoreStaticAssets: this.getConfig().ignoreStaticAssets,
- spans: spansEnabled && !this.getConfig().disableIncomingRequestSpans,
- });
- }) satisfies ChannelListener;
-
const onHttpClientResponseFinish = ((_data: unknown) => {
const data = _data as { request: http.ClientRequest; response: http.IncomingMessage };
this._onOutgoingRequestFinish(data.request, data.response);
@@ -220,7 +162,6 @@ export class SentryHttpInstrumentation extends InstrumentationBase {
- unsubscribe('http.server.request.start', onHttpServerRequestStart);
unsubscribe('http.client.response.finish', onHttpClientResponseFinish);
unsubscribe('http.client.request.error', onHttpClientRequestError);
unsubscribe('http.client.request.created', onHttpClientRequestCreated);
diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts
new file mode 100644
index 000000000000..f37ddc07a125
--- /dev/null
+++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts
@@ -0,0 +1,436 @@
+import type { ChannelListener } from 'node:diagnostics_channel';
+import { subscribe } from 'node:diagnostics_channel';
+import type { EventEmitter } from 'node:events';
+import type { IncomingMessage, RequestOptions, Server, ServerResponse } from 'node:http';
+import type { Socket } from 'node:net';
+import { context, createContextKey, propagation } from '@opentelemetry/api';
+import type { AggregationCounts, Client, Integration, IntegrationFn, Scope } from '@sentry/core';
+import {
+ addNonEnumerableProperty,
+ debug,
+ generateSpanId,
+ getClient,
+ getCurrentScope,
+ getIsolationScope,
+ httpRequestToRequestData,
+ stripUrlQueryAndFragment,
+ withIsolationScope,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../../debug-build';
+import type { NodeClient } from '../../sdk/client';
+import { MAX_BODY_BYTE_LENGTH } from './constants';
+
+type ServerEmit = typeof Server.prototype.emit;
+
+// Inlining this type to not depend on newer TS types
+interface WeakRefImpl {
+ deref(): T | undefined;
+}
+
+type StartSpanCallback = (next: () => boolean) => boolean;
+type RequestWithOptionalStartSpanCallback = IncomingMessage & {
+ _startSpanCallback?: WeakRefImpl;
+};
+
+const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented');
+const INTEGRATION_NAME = 'Http.Server';
+
+const clientToRequestSessionAggregatesMap = new Map<
+ Client,
+ { [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } }
+>();
+
+// We keep track of emit functions we wrapped, to avoid double wrapping
+// We do this instead of putting a non-enumerable property on the function, because
+// sometimes the property seems to be migrated to forks of the emit function, which we do not want to happen
+// This was the case in the nestjs-distributed-tracing E2E test
+const wrappedEmitFns = new WeakSet();
+
+export interface HttpServerIntegrationOptions {
+ /**
+ * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry.
+ * Read more about Release Health: https://docs.sentry.io/product/releases/health/
+ *
+ * Defaults to `true`.
+ */
+ sessions?: boolean;
+
+ /**
+ * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate.
+ *
+ * Defaults to `60000` (60s).
+ */
+ sessionFlushingDelayMS?: number;
+
+ /**
+ * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`.
+ * This can be useful for long running requests where the body is not needed and we want to avoid capturing it.
+ *
+ * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request.
+ * @param request Contains the {@type RequestOptions} object used to make the incoming request.
+ */
+ ignoreRequestBody?: (url: string, request: RequestOptions) => boolean;
+
+ /**
+ * Controls the maximum size of incoming HTTP request bodies attached to events.
+ *
+ * Available options:
+ * - 'none': No request bodies will be attached
+ * - 'small': Request bodies up to 1,000 bytes will be attached
+ * - 'medium': Request bodies up to 10,000 bytes will be attached (default)
+ * - 'always': Request bodies will always be attached
+ *
+ * Note that even with 'always' setting, bodies exceeding 1MB will never be attached
+ * for performance and security reasons.
+ *
+ * @default 'medium'
+ */
+ maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always';
+}
+
+/**
+ * Add a callback to the request object that will be called when the request is started.
+ * The callback will receive the next function to continue processing the request.
+ */
+export function addStartSpanCallback(request: RequestWithOptionalStartSpanCallback, callback: StartSpanCallback): void {
+ addNonEnumerableProperty(request, '_startSpanCallback', new WeakRef(callback));
+}
+
+const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => {
+ const _options = {
+ sessions: options.sessions ?? true,
+ sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60_000,
+ maxRequestBodySize: options.maxRequestBodySize ?? 'medium',
+ ignoreRequestBody: options.ignoreRequestBody,
+ };
+
+ return {
+ name: INTEGRATION_NAME,
+ setupOnce() {
+ const onHttpServerRequestStart = ((_data: unknown) => {
+ const data = _data as { server: Server };
+
+ instrumentServer(data.server, _options);
+ }) satisfies ChannelListener;
+
+ subscribe('http.server.request.start', onHttpServerRequestStart);
+ },
+ afterAllSetup(client) {
+ if (DEBUG_BUILD && client.getIntegrationByName('Http')) {
+ debug.warn(
+ 'It seems that you have manually added `httpServerIntegration` while `httpIntegration` is also present. Make sure to remove `httpServerIntegration` when adding `httpIntegration`.',
+ );
+ }
+ },
+ };
+}) satisfies IntegrationFn;
+
+/**
+ * This integration handles request isolation, trace continuation and other core Sentry functionality around incoming http requests
+ * handled via the node `http` module.
+ */
+export const httpServerIntegration = _httpServerIntegration as (
+ options?: HttpServerIntegrationOptions,
+) => Integration & {
+ name: 'HttpServer';
+ setupOnce: () => void;
+};
+
+/**
+ * Instrument a server to capture incoming requests.
+ *
+ */
+function instrumentServer(
+ server: Server,
+ {
+ ignoreRequestBody,
+ maxRequestBodySize,
+ sessions,
+ sessionFlushingDelayMS,
+ }: {
+ ignoreRequestBody?: (url: string, request: IncomingMessage) => boolean;
+ maxRequestBodySize: 'small' | 'medium' | 'always' | 'none';
+ sessions: boolean;
+ sessionFlushingDelayMS: number;
+ },
+): void {
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const originalEmit: ServerEmit = server.emit;
+
+ if (wrappedEmitFns.has(originalEmit)) {
+ return;
+ }
+
+ const newEmit = new Proxy(originalEmit, {
+ apply(target, thisArg, args: [event: string, ...args: unknown[]]) {
+ // Only traces request events
+ if (args[0] !== 'request') {
+ return target.apply(thisArg, args);
+ }
+
+ const client = getClient();
+
+ // Make sure we do not double execute our wrapper code, for edge cases...
+ // Without this check, if we double-wrap emit, for whatever reason, you'd get two http.server spans (one the children of the other)
+ if (context.active().getValue(HTTP_SERVER_INSTRUMENTED_KEY) || !client) {
+ return target.apply(thisArg, args);
+ }
+
+ DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling incoming request');
+
+ const isolationScope = getIsolationScope().clone();
+ const request = args[1] as IncomingMessage;
+ const response = args[2] as ServerResponse & { socket: Socket };
+
+ const normalizedRequest = httpRequestToRequestData(request);
+
+ // request.ip is non-standard but some frameworks set this
+ const ipAddress = (request as { ip?: string }).ip || request.socket?.remoteAddress;
+
+ const url = request.url || '/';
+ if (maxRequestBodySize !== 'none' && !ignoreRequestBody?.(url, request)) {
+ patchRequestToCaptureBody(request, isolationScope, maxRequestBodySize);
+ }
+
+ // Update the isolation scope, isolate this request
+ isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress });
+
+ // attempt to update the scope's `transactionName` based on the request URL
+ // Ideally, framework instrumentations coming after the HttpInstrumentation
+ // update the transactionName once we get a parameterized route.
+ const httpMethod = (request.method || 'GET').toUpperCase();
+ const httpTargetWithoutQueryFragment = stripUrlQueryAndFragment(url);
+
+ const bestEffortTransactionName = `${httpMethod} ${httpTargetWithoutQueryFragment}`;
+
+ isolationScope.setTransactionName(bestEffortTransactionName);
+
+ if (sessions && client) {
+ recordRequestSession(client, {
+ requestIsolationScope: isolationScope,
+ response,
+ sessionFlushingDelayMS: sessionFlushingDelayMS ?? 60_000,
+ });
+ }
+
+ return withIsolationScope(isolationScope, () => {
+ // Set a new propagationSpanId for this request
+ // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope
+ // This way we can save an "unnecessary" `withScope()` invocation
+ getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId();
+
+ const ctx = propagation
+ .extract(context.active(), normalizedRequest.headers)
+ .setValue(HTTP_SERVER_INSTRUMENTED_KEY, true);
+
+ return context.with(ctx, () => {
+ // This is used (optionally) by the httpServerSpansIntegration to attach _startSpanCallback to the request object
+ client.emit('httpServerRequest', request, response, normalizedRequest);
+
+ const callback = (request as RequestWithOptionalStartSpanCallback)._startSpanCallback?.deref();
+ if (callback) {
+ return callback(() => target.apply(thisArg, args));
+ }
+ return target.apply(thisArg, args);
+ });
+ });
+ },
+ });
+
+ wrappedEmitFns.add(newEmit);
+ server.emit = newEmit;
+}
+
+/**
+ * Starts a session and tracks it in the context of a given isolation scope.
+ * When the passed response is finished, the session is put into a task and is
+ * aggregated with other sessions that may happen in a certain time window
+ * (sessionFlushingDelayMs).
+ *
+ * The sessions are always aggregated by the client that is on the current scope
+ * at the time of ending the response (if there is one).
+ */
+// Exported for unit tests
+export function recordRequestSession(
+ client: Client,
+ {
+ requestIsolationScope,
+ response,
+ sessionFlushingDelayMS,
+ }: {
+ requestIsolationScope: Scope;
+ response: EventEmitter;
+ sessionFlushingDelayMS?: number;
+ },
+): void {
+ requestIsolationScope.setSDKProcessingMetadata({
+ requestSession: { status: 'ok' },
+ });
+ response.once('close', () => {
+ const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession;
+
+ if (client && requestSession) {
+ DEBUG_BUILD && debug.log(`Recorded request session with status: ${requestSession.status}`);
+
+ const roundedDate = new Date();
+ roundedDate.setSeconds(0, 0);
+ const dateBucketKey = roundedDate.toISOString();
+
+ const existingClientAggregate = clientToRequestSessionAggregatesMap.get(client);
+ const bucket = existingClientAggregate?.[dateBucketKey] || { exited: 0, crashed: 0, errored: 0 };
+ bucket[({ ok: 'exited', crashed: 'crashed', errored: 'errored' } as const)[requestSession.status]]++;
+
+ if (existingClientAggregate) {
+ existingClientAggregate[dateBucketKey] = bucket;
+ } else {
+ DEBUG_BUILD && debug.log('Opened new request session aggregate.');
+ const newClientAggregate = { [dateBucketKey]: bucket };
+ clientToRequestSessionAggregatesMap.set(client, newClientAggregate);
+
+ const flushPendingClientAggregates = (): void => {
+ clearTimeout(timeout);
+ unregisterClientFlushHook();
+ clientToRequestSessionAggregatesMap.delete(client);
+
+ const aggregatePayload: AggregationCounts[] = Object.entries(newClientAggregate).map(
+ ([timestamp, value]) => ({
+ started: timestamp,
+ exited: value.exited,
+ errored: value.errored,
+ crashed: value.crashed,
+ }),
+ );
+ client.sendSession({ aggregates: aggregatePayload });
+ };
+
+ const unregisterClientFlushHook = client.on('flush', () => {
+ DEBUG_BUILD && debug.log('Sending request session aggregate due to client flush');
+ flushPendingClientAggregates();
+ });
+ const timeout = setTimeout(() => {
+ DEBUG_BUILD && debug.log('Sending request session aggregate due to flushing schedule');
+ flushPendingClientAggregates();
+ }, sessionFlushingDelayMS).unref();
+ }
+ }
+ });
+}
+
+/**
+ * This method patches the request object to capture the body.
+ * Instead of actually consuming the streamed body ourselves, which has potential side effects,
+ * we monkey patch `req.on('data')` to intercept the body chunks.
+ * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways.
+ */
+function patchRequestToCaptureBody(
+ req: IncomingMessage,
+ isolationScope: Scope,
+ maxIncomingRequestBodySize: 'small' | 'medium' | 'always',
+): void {
+ let bodyByteLength = 0;
+ const chunks: Buffer[] = [];
+
+ DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Patching request.on');
+
+ /**
+ * We need to keep track of the original callbacks, in order to be able to remove listeners again.
+ * Since `off` depends on having the exact same function reference passed in, we need to be able to map
+ * original listeners to our wrapped ones.
+ */
+ const callbackMap = new WeakMap();
+
+ const maxBodySize =
+ maxIncomingRequestBodySize === 'small'
+ ? 1_000
+ : maxIncomingRequestBodySize === 'medium'
+ ? 10_000
+ : MAX_BODY_BYTE_LENGTH;
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ req.on = new Proxy(req.on, {
+ apply: (target, thisArg, args: Parameters) => {
+ const [event, listener, ...restArgs] = args;
+
+ if (event === 'data') {
+ DEBUG_BUILD &&
+ debug.log(INTEGRATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`);
+
+ const callback = new Proxy(listener, {
+ apply: (target, thisArg, args: Parameters) => {
+ try {
+ const chunk = args[0] as Buffer | string;
+ const bufferifiedChunk = Buffer.from(chunk);
+
+ if (bodyByteLength < maxBodySize) {
+ chunks.push(bufferifiedChunk);
+ bodyByteLength += bufferifiedChunk.byteLength;
+ } else if (DEBUG_BUILD) {
+ debug.log(
+ INTEGRATION_NAME,
+ `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`,
+ );
+ }
+ } catch (err) {
+ DEBUG_BUILD && debug.error(INTEGRATION_NAME, 'Encountered error while storing body chunk.');
+ }
+
+ return Reflect.apply(target, thisArg, args);
+ },
+ });
+
+ callbackMap.set(listener, callback);
+
+ return Reflect.apply(target, thisArg, [event, callback, ...restArgs]);
+ }
+
+ return Reflect.apply(target, thisArg, args);
+ },
+ });
+
+ // Ensure we also remove callbacks correctly
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ req.off = new Proxy(req.off, {
+ apply: (target, thisArg, args: Parameters) => {
+ const [, listener] = args;
+
+ const callback = callbackMap.get(listener);
+ if (callback) {
+ callbackMap.delete(listener);
+
+ const modifiedArgs = args.slice();
+ modifiedArgs[1] = callback;
+ return Reflect.apply(target, thisArg, modifiedArgs);
+ }
+
+ return Reflect.apply(target, thisArg, args);
+ },
+ });
+
+ req.on('end', () => {
+ try {
+ const body = Buffer.concat(chunks).toString('utf-8');
+ if (body) {
+ // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long
+ const bodyByteLength = Buffer.byteLength(body, 'utf-8');
+ const truncatedBody =
+ bodyByteLength > maxBodySize
+ ? `${Buffer.from(body)
+ .subarray(0, maxBodySize - 3)
+ .toString('utf-8')}...`
+ : body;
+
+ isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } });
+ }
+ } catch (error) {
+ if (DEBUG_BUILD) {
+ debug.error(INTEGRATION_NAME, 'Error building captured request body', error);
+ }
+ }
+ });
+ } catch (error) {
+ if (DEBUG_BUILD) {
+ debug.error(INTEGRATION_NAME, 'Error patching request to capture body', error);
+ }
+ }
+}
diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts
new file mode 100644
index 000000000000..c24c0c68d1da
--- /dev/null
+++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts
@@ -0,0 +1,407 @@
+import { errorMonitor } from 'node:events';
+import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
+import { context, SpanKind, trace } from '@opentelemetry/api';
+import type { RPCMetadata } from '@opentelemetry/core';
+import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core';
+import {
+ ATTR_HTTP_RESPONSE_STATUS_CODE,
+ ATTR_HTTP_ROUTE,
+ SEMATTRS_HTTP_STATUS_CODE,
+ SEMATTRS_NET_HOST_IP,
+ SEMATTRS_NET_HOST_PORT,
+ SEMATTRS_NET_PEER_IP,
+} from '@opentelemetry/semantic-conventions';
+import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core';
+import {
+ debug,
+ getIsolationScope,
+ getSpanStatusFromHttpCode,
+ httpHeadersToSpanAttributes,
+ parseStringToURLObject,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SPAN_STATUS_ERROR,
+ stripUrlQueryAndFragment,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../../debug-build';
+import type { NodeClient } from '../../sdk/client';
+import { addStartSpanCallback } from './httpServerIntegration';
+
+const INTEGRATION_NAME = 'Http.ServerSpans';
+
+// Tree-shakable guard to remove all code related to tracing
+declare const __SENTRY_TRACING__: boolean;
+
+export interface HttpServerSpansIntegrationOptions {
+ /**
+ * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`.
+ * Spans will be non recording if tracing is disabled.
+ *
+ * The `urlPath` param consists of the URL path and query string (if any) of the incoming request.
+ * For example: `'/users/details?id=123'`
+ *
+ * The `request` param contains the original {@type IncomingMessage} object of the incoming request.
+ * You can use it to filter on additional properties like method, headers, etc.
+ */
+ ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean;
+
+ /**
+ * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc.
+ * This helps reduce noise in your transactions.
+ *
+ * @default `true`
+ */
+ ignoreStaticAssets?: boolean;
+
+ /**
+ * Do not capture spans for incoming HTTP requests with the given status codes.
+ * By default, spans with some 3xx and 4xx status codes are ignored (see @default).
+ * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes.
+ *
+ * @default `[[401, 404], [301, 303], [305, 399]]`
+ */
+ ignoreStatusCodes?: (number | [number, number])[];
+
+ /**
+ * @deprecated This is deprecated in favor of `incomingRequestSpanHook`.
+ */
+ instrumentation?: {
+ requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void;
+ responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void;
+ applyCustomAttributesOnSpan?: (
+ span: Span,
+ request: ClientRequest | IncomingMessage,
+ response: IncomingMessage | ServerResponse,
+ ) => void;
+ };
+
+ /**
+ * A hook that can be used to mutate the span for incoming requests.
+ * This is triggered after the span is created, but before it is recorded.
+ */
+ onSpanCreated?: (span: Span, request: IncomingMessage, response: ServerResponse) => void;
+}
+
+const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => {
+ const ignoreStaticAssets = options.ignoreStaticAssets ?? true;
+ const ignoreIncomingRequests = options.ignoreIncomingRequests;
+ const ignoreStatusCodes = options.ignoreStatusCodes ?? [
+ [401, 404],
+ // 300 and 304 are possibly valid status codes we do not want to filter
+ [301, 303],
+ [305, 399],
+ ];
+
+ const { onSpanCreated } = options;
+ // eslint-disable-next-line deprecation/deprecation
+ const { requestHook, responseHook, applyCustomAttributesOnSpan } = options.instrumentation ?? {};
+
+ return {
+ name: INTEGRATION_NAME,
+ setup(client: NodeClient) {
+ // If no tracing, we can just skip everything here
+ if (typeof __SENTRY_TRACING__ !== 'undefined' && !__SENTRY_TRACING__) {
+ return;
+ }
+
+ client.on('httpServerRequest', (_request, _response, normalizedRequest) => {
+ // Type-casting this here because we do not want to put the node types into core
+ const request = _request as IncomingMessage;
+ const response = _response as ServerResponse;
+
+ const startSpan = (next: () => boolean): boolean => {
+ if (
+ shouldIgnoreSpansForIncomingRequest(request, {
+ ignoreStaticAssets,
+ ignoreIncomingRequests,
+ })
+ ) {
+ DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Skipping span creation for incoming request', request.url);
+ return next();
+ }
+
+ const fullUrl = normalizedRequest.url || request.url || '/';
+ const urlObj = parseStringToURLObject(fullUrl);
+
+ const headers = request.headers;
+ const userAgent = headers['user-agent'];
+ const ips = headers['x-forwarded-for'];
+ const httpVersion = request.httpVersion;
+ const host = headers.host;
+ const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost';
+
+ const tracer = client.tracer;
+ const scheme = fullUrl.startsWith('https') ? 'https' : 'http';
+
+ const method = normalizedRequest.method || request.method?.toUpperCase() || 'GET';
+ const httpTargetWithoutQueryFragment = urlObj ? urlObj.pathname : stripUrlQueryAndFragment(fullUrl);
+ const bestEffortTransactionName = `${method} ${httpTargetWithoutQueryFragment}`;
+ const shouldSendDefaultPii = client.getOptions().sendDefaultPii ?? false;
+
+ // We use the plain tracer.startSpan here so we can pass the span kind
+ const span = tracer.startSpan(bestEffortTransactionName, {
+ kind: SpanKind.SERVER,
+ attributes: {
+ // Sentry specific attributes
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http',
+ 'sentry.http.prefetch': isKnownPrefetchRequest(request) || undefined,
+ // Old Semantic Conventions attributes - added for compatibility with what `@opentelemetry/instrumentation-http` output before
+ 'http.url': fullUrl,
+ 'http.method': normalizedRequest.method,
+ 'http.target': urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment,
+ 'http.host': host,
+ 'net.host.name': hostname,
+ 'http.client_ip': typeof ips === 'string' ? ips.split(',')[0] : undefined,
+ 'http.user_agent': userAgent,
+ 'http.scheme': scheme,
+ 'http.flavor': httpVersion,
+ 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp',
+ ...getRequestContentLengthAttribute(request),
+ ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, shouldSendDefaultPii),
+ },
+ });
+
+ // TODO v11: Remove the following three hooks, only onSpanCreated should remain
+ requestHook?.(span, request);
+ responseHook?.(span, response);
+ applyCustomAttributesOnSpan?.(span, request, response);
+ onSpanCreated?.(span, request, response);
+
+ const rpcMetadata: RPCMetadata = {
+ type: RPCType.HTTP,
+ span,
+ };
+
+ return context.with(setRPCMetadata(trace.setSpan(context.active(), span), rpcMetadata), () => {
+ context.bind(context.active(), request);
+ context.bind(context.active(), response);
+
+ // Ensure we only end the span once
+ // E.g. error can be emitted before close is emitted
+ let isEnded = false;
+ function endSpan(status: SpanStatus): void {
+ if (isEnded) {
+ return;
+ }
+
+ isEnded = true;
+
+ const newAttributes = getIncomingRequestAttributesOnResponse(request, response);
+ span.setAttributes(newAttributes);
+ span.setStatus(status);
+ span.end();
+
+ // Update the transaction name if the route has changed
+ const route = newAttributes['http.route'];
+ if (route) {
+ getIsolationScope().setTransactionName(`${request.method?.toUpperCase() || 'GET'} ${route}`);
+ }
+ }
+
+ response.on('close', () => {
+ endSpan(getSpanStatusFromHttpCode(response.statusCode));
+ });
+ response.on(errorMonitor, () => {
+ const httpStatus = getSpanStatusFromHttpCode(response.statusCode);
+ // Ensure we def. have an error status here
+ endSpan(httpStatus.code === SPAN_STATUS_ERROR ? httpStatus : { code: SPAN_STATUS_ERROR });
+ });
+
+ return next();
+ });
+ };
+
+ addStartSpanCallback(request, startSpan);
+ });
+ },
+ processEvent(event) {
+ // Drop transaction if it has a status code that should be ignored
+ if (event.type === 'transaction') {
+ const statusCode = event.contexts?.trace?.data?.['http.response.status_code'];
+ if (typeof statusCode === 'number') {
+ const shouldDrop = shouldFilterStatusCode(statusCode, ignoreStatusCodes);
+ if (shouldDrop) {
+ DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode);
+ return null;
+ }
+ }
+ }
+
+ return event;
+ },
+ afterAllSetup(client) {
+ if (!DEBUG_BUILD) {
+ return;
+ }
+
+ if (client.getIntegrationByName('Http')) {
+ debug.warn(
+ 'It seems that you have manually added `httpServerSpansIntergation` while `httpIntegration` is also present. Make sure to remove `httpIntegration` when adding `httpServerSpansIntegration`.',
+ );
+ }
+
+ if (!client.getIntegrationByName('Http.Server')) {
+ debug.error(
+ 'It seems that you have manually added `httpServerSpansIntergation` without adding `httpServerIntegration`. This is a requiement for spans to be created - please add the `httpServerIntegration` integration.',
+ );
+ }
+ },
+ };
+}) satisfies IntegrationFn;
+
+/**
+ * This integration emits spans for incoming requests handled via the node `http` module.
+ * It requires the `httpServerIntegration` to be present.
+ */
+export const httpServerSpansIntegration = _httpServerSpansIntegration as (
+ options?: HttpServerSpansIntegrationOptions,
+) => Integration & {
+ name: 'HttpServerSpans';
+ setup: (client: NodeClient) => void;
+ processEvent: (event: Event) => Event | null;
+};
+
+function isKnownPrefetchRequest(req: IncomingMessage): boolean {
+ // Currently only handles Next.js prefetch requests but may check other frameworks in the future.
+ return req.headers['next-router-prefetch'] === '1';
+}
+
+/**
+ * Check if a request is for a common static asset that should be ignored by default.
+ *
+ * Only exported for tests.
+ */
+export function isStaticAssetRequest(urlPath: string): boolean {
+ const path = stripUrlQueryAndFragment(urlPath);
+ // Common static file extensions
+ if (path.match(/\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2|ttf|eot|webp|avif)$/)) {
+ return true;
+ }
+
+ // Common metadata files
+ if (path.match(/^\/(robots\.txt|sitemap\.xml|manifest\.json|browserconfig\.xml)$/)) {
+ return true;
+ }
+
+ return false;
+}
+
+function shouldIgnoreSpansForIncomingRequest(
+ request: IncomingMessage,
+ {
+ ignoreStaticAssets,
+ ignoreIncomingRequests,
+ }: {
+ ignoreStaticAssets?: boolean;
+ ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean;
+ },
+): boolean {
+ if (isTracingSuppressed(context.active())) {
+ return true;
+ }
+
+ // request.url is the only property that holds any information about the url
+ // it only consists of the URL path and query string (if any)
+ const urlPath = request.url;
+
+ const method = request.method?.toUpperCase();
+ // We do not capture OPTIONS/HEAD requests as spans
+ if (method === 'OPTIONS' || method === 'HEAD' || !urlPath) {
+ return true;
+ }
+
+ // Default static asset filtering
+ if (ignoreStaticAssets && method === 'GET' && isStaticAssetRequest(urlPath)) {
+ return true;
+ }
+
+ if (ignoreIncomingRequests?.(urlPath, request)) {
+ return true;
+ }
+
+ return false;
+}
+
+function getRequestContentLengthAttribute(request: IncomingMessage): SpanAttributes {
+ const length = getContentLength(request.headers);
+ if (length == null) {
+ return {};
+ }
+
+ if (isCompressed(request.headers)) {
+ return {
+ ['http.request_content_length']: length,
+ };
+ } else {
+ return {
+ ['http.request_content_length_uncompressed']: length,
+ };
+ }
+}
+
+function getContentLength(headers: IncomingHttpHeaders): number | null {
+ const contentLengthHeader = headers['content-length'];
+ if (contentLengthHeader === undefined) return null;
+
+ const contentLength = parseInt(contentLengthHeader, 10);
+ if (isNaN(contentLength)) return null;
+
+ return contentLength;
+}
+
+function isCompressed(headers: IncomingHttpHeaders): boolean {
+ const encoding = headers['content-encoding'];
+
+ return !!encoding && encoding !== 'identity';
+}
+
+function getIncomingRequestAttributesOnResponse(request: IncomingMessage, response: ServerResponse): SpanAttributes {
+ // take socket from the request,
+ // since it may be detached from the response object in keep-alive mode
+ const { socket } = request;
+ const { statusCode, statusMessage } = response;
+
+ const newAttributes: SpanAttributes = {
+ [ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode,
+ // eslint-disable-next-line deprecation/deprecation
+ [SEMATTRS_HTTP_STATUS_CODE]: statusCode,
+ 'http.status_text': statusMessage?.toUpperCase(),
+ };
+
+ const rpcMetadata = getRPCMetadata(context.active());
+ if (socket) {
+ const { localAddress, localPort, remoteAddress, remotePort } = socket;
+ // eslint-disable-next-line deprecation/deprecation
+ newAttributes[SEMATTRS_NET_HOST_IP] = localAddress;
+ // eslint-disable-next-line deprecation/deprecation
+ newAttributes[SEMATTRS_NET_HOST_PORT] = localPort;
+ // eslint-disable-next-line deprecation/deprecation
+ newAttributes[SEMATTRS_NET_PEER_IP] = remoteAddress;
+ newAttributes['net.peer.port'] = remotePort;
+ }
+ // eslint-disable-next-line deprecation/deprecation
+ newAttributes[SEMATTRS_HTTP_STATUS_CODE] = statusCode;
+ newAttributes['http.status_text'] = (statusMessage || '').toUpperCase();
+
+ if (rpcMetadata?.type === RPCType.HTTP && rpcMetadata.route !== undefined) {
+ const routeName = rpcMetadata.route;
+ newAttributes[ATTR_HTTP_ROUTE] = routeName;
+ }
+
+ return newAttributes;
+}
+
+/**
+ * If the given status code should be filtered for the given list of status codes/ranges.
+ */
+function shouldFilterStatusCode(statusCode: number, dropForStatusCodes: (number | [number, number])[]): boolean {
+ return dropForStatusCodes.some(code => {
+ if (typeof code === 'number') {
+ return code === statusCode;
+ }
+
+ const [min, max] = code;
+ return statusCode >= min && statusCode <= max;
+ });
+}
diff --git a/packages/node-core/src/integrations/http/incoming-requests.ts b/packages/node-core/src/integrations/http/incoming-requests.ts
deleted file mode 100644
index e2de19f77582..000000000000
--- a/packages/node-core/src/integrations/http/incoming-requests.ts
+++ /dev/null
@@ -1,599 +0,0 @@
-/* eslint-disable max-lines */
-import type { Span } from '@opentelemetry/api';
-import { context, createContextKey, propagation, SpanKind, trace } from '@opentelemetry/api';
-import type { RPCMetadata } from '@opentelemetry/core';
-import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core';
-import {
- ATTR_HTTP_RESPONSE_STATUS_CODE,
- ATTR_HTTP_ROUTE,
- SEMATTRS_HTTP_STATUS_CODE,
- SEMATTRS_NET_HOST_IP,
- SEMATTRS_NET_HOST_PORT,
- SEMATTRS_NET_PEER_IP,
-} from '@opentelemetry/semantic-conventions';
-import type { AggregationCounts, Client, Scope, SpanAttributes, SpanStatus } from '@sentry/core';
-import {
- debug,
- generateSpanId,
- getClient,
- getCurrentScope,
- getIsolationScope,
- getSpanStatusFromHttpCode,
- httpHeadersToSpanAttributes,
- httpRequestToRequestData,
- parseStringToURLObject,
- SEMANTIC_ATTRIBUTE_SENTRY_OP,
- SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
- SPAN_STATUS_ERROR,
- stripUrlQueryAndFragment,
- withIsolationScope,
-} from '@sentry/core';
-import type EventEmitter from 'events';
-import { errorMonitor } from 'events';
-import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from 'http';
-import type { Socket } from 'net';
-import { DEBUG_BUILD } from '../../debug-build';
-import type { NodeClient } from '../../sdk/client';
-import { INSTRUMENTATION_NAME, MAX_BODY_BYTE_LENGTH } from './constants';
-
-// Tree-shakable guard to remove all code related to tracing
-declare const __SENTRY_TRACING__: boolean;
-
-type ServerEmit = typeof Server.prototype.emit;
-
-const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented');
-
-const clientToRequestSessionAggregatesMap = new Map<
- Client,
- { [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } }
->();
-
-// We keep track of emit functions we wrapped, to avoid double wrapping
-// We do this instead of putting a non-enumerable property on the function, because
-// sometimes the property seems to be migrated to forks of the emit function, which we do not want to happen
-// This was the case in the nestjs-distributed-tracing E2E test
-const wrappedEmitFns = new WeakSet();
-
-/**
- * Instrument a server to capture incoming requests.
- *
- */
-export function instrumentServer(
- server: Server,
- {
- ignoreIncomingRequestBody,
- ignoreSpansForIncomingRequests,
- maxIncomingRequestBodySize = 'medium',
- trackIncomingRequestsAsSessions = true,
- spans,
- ignoreStaticAssets = true,
- sessionFlushingDelayMS,
- // eslint-disable-next-line deprecation/deprecation
- instrumentation,
- incomingRequestSpanHook,
- }: {
- ignoreIncomingRequestBody?: (url: string, request: IncomingMessage) => boolean;
- ignoreSpansForIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean;
- maxIncomingRequestBodySize?: 'small' | 'medium' | 'always' | 'none';
- trackIncomingRequestsAsSessions?: boolean;
- sessionFlushingDelayMS: number;
- spans: boolean;
- ignoreStaticAssets?: boolean;
- incomingRequestSpanHook?: (span: Span, request: IncomingMessage, response: ServerResponse) => void;
- /** @deprecated Use `incomingRequestSpanHook` instead. */
- instrumentation?: {
- requestHook?: (span: Span, req: IncomingMessage | ClientRequest) => void;
- responseHook?: (span: Span, response: ServerResponse | IncomingMessage) => void;
- applyCustomAttributesOnSpan?: (
- span: Span,
- request: IncomingMessage | ClientRequest,
- response: ServerResponse | IncomingMessage,
- ) => void;
- };
- },
-): void {
- // eslint-disable-next-line @typescript-eslint/unbound-method
- const originalEmit: ServerEmit = server.emit;
-
- if (wrappedEmitFns.has(originalEmit)) {
- DEBUG_BUILD &&
- debug.log(INSTRUMENTATION_NAME, 'Incoming requests already instrumented, not instrumenting again...');
- return;
- }
-
- const { requestHook, responseHook, applyCustomAttributesOnSpan } = instrumentation ?? {};
-
- const newEmit = new Proxy(originalEmit, {
- apply(target, thisArg, args: [event: string, ...args: unknown[]]) {
- // Only traces request events
- if (args[0] !== 'request') {
- return target.apply(thisArg, args);
- }
-
- // Make sure we do not double execute our wrapper code, for edge cases...
- // Without this check, if we double-wrap emit, for whatever reason, you'd get two http.server spans (one the children of the other)
- if (context.active().getValue(HTTP_SERVER_INSTRUMENTED_KEY)) {
- return target.apply(thisArg, args);
- }
-
- DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling incoming request');
-
- const client = getClient();
- const isolationScope = getIsolationScope().clone();
- const request = args[1] as IncomingMessage;
- const response = args[2] as ServerResponse & { socket: Socket };
-
- const normalizedRequest = httpRequestToRequestData(request);
-
- // request.ip is non-standard but some frameworks set this
- const ipAddress = (request as { ip?: string }).ip || request.socket?.remoteAddress;
-
- const url = request.url || '/';
- if (maxIncomingRequestBodySize !== 'none' && !ignoreIncomingRequestBody?.(url, request)) {
- patchRequestToCaptureBody(request, isolationScope, maxIncomingRequestBodySize);
- }
-
- // Update the isolation scope, isolate this request
- isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress });
-
- // attempt to update the scope's `transactionName` based on the request URL
- // Ideally, framework instrumentations coming after the HttpInstrumentation
- // update the transactionName once we get a parameterized route.
- const httpMethod = (request.method || 'GET').toUpperCase();
- const httpTargetWithoutQueryFragment = stripUrlQueryAndFragment(url);
-
- const bestEffortTransactionName = `${httpMethod} ${httpTargetWithoutQueryFragment}`;
-
- isolationScope.setTransactionName(bestEffortTransactionName);
-
- if (trackIncomingRequestsAsSessions !== false) {
- recordRequestSession({
- requestIsolationScope: isolationScope,
- response,
- sessionFlushingDelayMS: sessionFlushingDelayMS ?? 60_000,
- });
- }
-
- return withIsolationScope(isolationScope, () => {
- // Set a new propagationSpanId for this request
- // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope
- // This way we can save an "unnecessary" `withScope()` invocation
- getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId();
-
- const ctx = propagation
- .extract(context.active(), normalizedRequest.headers)
- .setValue(HTTP_SERVER_INSTRUMENTED_KEY, true);
-
- return context.with(ctx, () => {
- // if opting out of span creation, we can end here
- if (
- (typeof __SENTRY_TRACING__ !== 'undefined' && !__SENTRY_TRACING__) ||
- !spans ||
- !client ||
- shouldIgnoreSpansForIncomingRequest(request, {
- ignoreStaticAssets,
- ignoreSpansForIncomingRequests,
- })
- ) {
- DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Skipping span creation for incoming request');
- return target.apply(thisArg, args);
- }
-
- const fullUrl = normalizedRequest.url || url;
- const urlObj = parseStringToURLObject(fullUrl);
-
- const headers = request.headers;
- const userAgent = headers['user-agent'];
- const ips = headers['x-forwarded-for'];
- const httpVersion = request.httpVersion;
- const host = headers.host;
- const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost';
-
- const tracer = client.tracer;
- const scheme = fullUrl.startsWith('https') ? 'https' : 'http';
-
- const shouldSendDefaultPii = client?.getOptions().sendDefaultPii ?? false;
-
- // We use the plain tracer.startSpan here so we can pass the span kind
- const span = tracer.startSpan(bestEffortTransactionName, {
- kind: SpanKind.SERVER,
- attributes: {
- // Sentry specific attributes
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http',
- 'sentry.http.prefetch': isKnownPrefetchRequest(request) || undefined,
- // Old Semantic Conventions attributes - added for compatibility with what `@opentelemetry/instrumentation-http` output before
- 'http.url': fullUrl,
- 'http.method': httpMethod,
- 'http.target': urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment,
- 'http.host': host,
- 'net.host.name': hostname,
- 'http.client_ip': typeof ips === 'string' ? ips.split(',')[0] : undefined,
- 'http.user_agent': userAgent,
- 'http.scheme': scheme,
- 'http.flavor': httpVersion,
- 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp',
- ...getRequestContentLengthAttribute(request),
- ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, shouldSendDefaultPii),
- },
- });
-
- // TODO v11: Remove the following three hooks, only incomingRequestSpanHook should remain
- requestHook?.(span, request);
- responseHook?.(span, response);
- applyCustomAttributesOnSpan?.(span, request, response);
- incomingRequestSpanHook?.(span, request, response);
-
- const rpcMetadata: RPCMetadata = {
- type: RPCType.HTTP,
- span,
- };
-
- context.with(setRPCMetadata(trace.setSpan(context.active(), span), rpcMetadata), () => {
- context.bind(context.active(), request);
- context.bind(context.active(), response);
-
- // Ensure we only end the span once
- // E.g. error can be emitted before close is emitted
- let isEnded = false;
- function endSpan(status: SpanStatus): void {
- if (isEnded) {
- return;
- }
-
- isEnded = true;
-
- const newAttributes = getIncomingRequestAttributesOnResponse(request, response);
- span.setAttributes(newAttributes);
- span.setStatus(status);
- span.end();
-
- // Update the transaction name if the route has changed
- const route = newAttributes['http.route'];
- if (route) {
- getIsolationScope().setTransactionName(`${request.method?.toUpperCase() || 'GET'} ${route}`);
- }
- }
-
- response.on('close', () => {
- endSpan(getSpanStatusFromHttpCode(response.statusCode));
- });
- response.on(errorMonitor, () => {
- const httpStatus = getSpanStatusFromHttpCode(response.statusCode);
- // Ensure we def. have an error status here
- endSpan(httpStatus.code === SPAN_STATUS_ERROR ? httpStatus : { code: SPAN_STATUS_ERROR });
- });
-
- return target.apply(thisArg, args);
- });
- });
- });
- },
- });
-
- wrappedEmitFns.add(newEmit);
- server.emit = newEmit;
-}
-
-/**
- * Starts a session and tracks it in the context of a given isolation scope.
- * When the passed response is finished, the session is put into a task and is
- * aggregated with other sessions that may happen in a certain time window
- * (sessionFlushingDelayMs).
- *
- * The sessions are always aggregated by the client that is on the current scope
- * at the time of ending the response (if there is one).
- */
-// Exported for unit tests
-export function recordRequestSession({
- requestIsolationScope,
- response,
- sessionFlushingDelayMS,
-}: {
- requestIsolationScope: Scope;
- response: EventEmitter;
- sessionFlushingDelayMS?: number;
-}): void {
- requestIsolationScope.setSDKProcessingMetadata({
- requestSession: { status: 'ok' },
- });
- response.once('close', () => {
- // We need to grab the client off the current scope instead of the isolation scope because the isolation scope doesn't hold any client out of the box.
- const client = getClient();
- const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession;
-
- if (client && requestSession) {
- DEBUG_BUILD && debug.log(`Recorded request session with status: ${requestSession.status}`);
-
- const roundedDate = new Date();
- roundedDate.setSeconds(0, 0);
- const dateBucketKey = roundedDate.toISOString();
-
- const existingClientAggregate = clientToRequestSessionAggregatesMap.get(client);
- const bucket = existingClientAggregate?.[dateBucketKey] || { exited: 0, crashed: 0, errored: 0 };
- bucket[({ ok: 'exited', crashed: 'crashed', errored: 'errored' } as const)[requestSession.status]]++;
-
- if (existingClientAggregate) {
- existingClientAggregate[dateBucketKey] = bucket;
- } else {
- DEBUG_BUILD && debug.log('Opened new request session aggregate.');
- const newClientAggregate = { [dateBucketKey]: bucket };
- clientToRequestSessionAggregatesMap.set(client, newClientAggregate);
-
- const flushPendingClientAggregates = (): void => {
- clearTimeout(timeout);
- unregisterClientFlushHook();
- clientToRequestSessionAggregatesMap.delete(client);
-
- const aggregatePayload: AggregationCounts[] = Object.entries(newClientAggregate).map(
- ([timestamp, value]) => ({
- started: timestamp,
- exited: value.exited,
- errored: value.errored,
- crashed: value.crashed,
- }),
- );
- client.sendSession({ aggregates: aggregatePayload });
- };
-
- const unregisterClientFlushHook = client.on('flush', () => {
- DEBUG_BUILD && debug.log('Sending request session aggregate due to client flush');
- flushPendingClientAggregates();
- });
- const timeout = setTimeout(() => {
- DEBUG_BUILD && debug.log('Sending request session aggregate due to flushing schedule');
- flushPendingClientAggregates();
- }, sessionFlushingDelayMS).unref();
- }
- }
- });
-}
-
-/**
- * This method patches the request object to capture the body.
- * Instead of actually consuming the streamed body ourselves, which has potential side effects,
- * we monkey patch `req.on('data')` to intercept the body chunks.
- * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways.
- */
-function patchRequestToCaptureBody(
- req: IncomingMessage,
- isolationScope: Scope,
- maxIncomingRequestBodySize: 'small' | 'medium' | 'always',
-): void {
- let bodyByteLength = 0;
- const chunks: Buffer[] = [];
-
- DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Patching request.on');
-
- /**
- * We need to keep track of the original callbacks, in order to be able to remove listeners again.
- * Since `off` depends on having the exact same function reference passed in, we need to be able to map
- * original listeners to our wrapped ones.
- */
- const callbackMap = new WeakMap();
-
- const maxBodySize =
- maxIncomingRequestBodySize === 'small'
- ? 1_000
- : maxIncomingRequestBodySize === 'medium'
- ? 10_000
- : MAX_BODY_BYTE_LENGTH;
-
- try {
- // eslint-disable-next-line @typescript-eslint/unbound-method
- req.on = new Proxy(req.on, {
- apply: (target, thisArg, args: Parameters) => {
- const [event, listener, ...restArgs] = args;
-
- if (event === 'data') {
- DEBUG_BUILD &&
- debug.log(INSTRUMENTATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`);
-
- const callback = new Proxy(listener, {
- apply: (target, thisArg, args: Parameters) => {
- try {
- const chunk = args[0] as Buffer | string;
- const bufferifiedChunk = Buffer.from(chunk);
-
- if (bodyByteLength < maxBodySize) {
- chunks.push(bufferifiedChunk);
- bodyByteLength += bufferifiedChunk.byteLength;
- } else if (DEBUG_BUILD) {
- debug.log(
- INSTRUMENTATION_NAME,
- `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`,
- );
- }
- } catch (err) {
- DEBUG_BUILD && debug.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.');
- }
-
- return Reflect.apply(target, thisArg, args);
- },
- });
-
- callbackMap.set(listener, callback);
-
- return Reflect.apply(target, thisArg, [event, callback, ...restArgs]);
- }
-
- return Reflect.apply(target, thisArg, args);
- },
- });
-
- // Ensure we also remove callbacks correctly
- // eslint-disable-next-line @typescript-eslint/unbound-method
- req.off = new Proxy(req.off, {
- apply: (target, thisArg, args: Parameters) => {
- const [, listener] = args;
-
- const callback = callbackMap.get(listener);
- if (callback) {
- callbackMap.delete(listener);
-
- const modifiedArgs = args.slice();
- modifiedArgs[1] = callback;
- return Reflect.apply(target, thisArg, modifiedArgs);
- }
-
- return Reflect.apply(target, thisArg, args);
- },
- });
-
- req.on('end', () => {
- try {
- const body = Buffer.concat(chunks).toString('utf-8');
- if (body) {
- // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long
- const bodyByteLength = Buffer.byteLength(body, 'utf-8');
- const truncatedBody =
- bodyByteLength > maxBodySize
- ? `${Buffer.from(body)
- .subarray(0, maxBodySize - 3)
- .toString('utf-8')}...`
- : body;
-
- isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } });
- }
- } catch (error) {
- if (DEBUG_BUILD) {
- debug.error(INSTRUMENTATION_NAME, 'Error building captured request body', error);
- }
- }
- });
- } catch (error) {
- if (DEBUG_BUILD) {
- debug.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error);
- }
- }
-}
-
-function getRequestContentLengthAttribute(request: IncomingMessage): SpanAttributes {
- const length = getContentLength(request.headers);
- if (length == null) {
- return {};
- }
-
- if (isCompressed(request.headers)) {
- return {
- ['http.request_content_length']: length,
- };
- } else {
- return {
- ['http.request_content_length_uncompressed']: length,
- };
- }
-}
-
-function getContentLength(headers: IncomingHttpHeaders): number | null {
- const contentLengthHeader = headers['content-length'];
- if (contentLengthHeader === undefined) return null;
-
- const contentLength = parseInt(contentLengthHeader, 10);
- if (isNaN(contentLength)) return null;
-
- return contentLength;
-}
-
-function isCompressed(headers: IncomingHttpHeaders): boolean {
- const encoding = headers['content-encoding'];
-
- return !!encoding && encoding !== 'identity';
-}
-
-function getIncomingRequestAttributesOnResponse(request: IncomingMessage, response: ServerResponse): SpanAttributes {
- // take socket from the request,
- // since it may be detached from the response object in keep-alive mode
- const { socket } = request;
- const { statusCode, statusMessage } = response;
-
- const newAttributes: SpanAttributes = {
- [ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode,
- // eslint-disable-next-line deprecation/deprecation
- [SEMATTRS_HTTP_STATUS_CODE]: statusCode,
- 'http.status_text': statusMessage?.toUpperCase(),
- };
-
- const rpcMetadata = getRPCMetadata(context.active());
- if (socket) {
- const { localAddress, localPort, remoteAddress, remotePort } = socket;
- // eslint-disable-next-line deprecation/deprecation
- newAttributes[SEMATTRS_NET_HOST_IP] = localAddress;
- // eslint-disable-next-line deprecation/deprecation
- newAttributes[SEMATTRS_NET_HOST_PORT] = localPort;
- // eslint-disable-next-line deprecation/deprecation
- newAttributes[SEMATTRS_NET_PEER_IP] = remoteAddress;
- newAttributes['net.peer.port'] = remotePort;
- }
- // eslint-disable-next-line deprecation/deprecation
- newAttributes[SEMATTRS_HTTP_STATUS_CODE] = statusCode;
- newAttributes['http.status_text'] = (statusMessage || '').toUpperCase();
-
- if (rpcMetadata?.type === RPCType.HTTP && rpcMetadata.route !== undefined) {
- const routeName = rpcMetadata.route;
- newAttributes[ATTR_HTTP_ROUTE] = routeName;
- }
-
- return newAttributes;
-}
-
-function isKnownPrefetchRequest(req: IncomingMessage): boolean {
- // Currently only handles Next.js prefetch requests but may check other frameworks in the future.
- return req.headers['next-router-prefetch'] === '1';
-}
-
-/**
- * Check if a request is for a common static asset that should be ignored by default.
- *
- * Only exported for tests.
- */
-export function isStaticAssetRequest(urlPath: string): boolean {
- const path = stripUrlQueryAndFragment(urlPath);
- // Common static file extensions
- if (path.match(/\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2|ttf|eot|webp|avif)$/)) {
- return true;
- }
-
- // Common metadata files
- if (path.match(/^\/(robots\.txt|sitemap\.xml|manifest\.json|browserconfig\.xml)$/)) {
- return true;
- }
-
- return false;
-}
-
-function shouldIgnoreSpansForIncomingRequest(
- request: IncomingMessage,
- {
- ignoreStaticAssets,
- ignoreSpansForIncomingRequests,
- }: {
- ignoreStaticAssets?: boolean;
- ignoreSpansForIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean;
- },
-): boolean {
- if (isTracingSuppressed(context.active())) {
- return true;
- }
-
- // request.url is the only property that holds any information about the url
- // it only consists of the URL path and query string (if any)
- const urlPath = request.url;
-
- const method = request.method?.toUpperCase();
- // We do not capture OPTIONS/HEAD requests as spans
- if (method === 'OPTIONS' || method === 'HEAD' || !urlPath) {
- return true;
- }
-
- // Default static asset filtering
- if (ignoreStaticAssets && method === 'GET' && isStaticAssetRequest(urlPath)) {
- return true;
- }
-
- if (ignoreSpansForIncomingRequests?.(urlPath, request)) {
- return true;
- }
-
- return false;
-}
diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts
index e89af730302d..19859b68f3c0 100644
--- a/packages/node-core/src/integrations/http/index.ts
+++ b/packages/node-core/src/integrations/http/index.ts
@@ -1,7 +1,11 @@
import type { IncomingMessage, RequestOptions } from 'node:http';
-import { debug, defineIntegration } from '@sentry/core';
-import { DEBUG_BUILD } from '../../debug-build';
+import { defineIntegration } from '@sentry/core';
import { generateInstrumentOnce } from '../../otel/instrument';
+import type { NodeClient } from '../../sdk/client';
+import type { HttpServerIntegrationOptions } from './httpServerIntegration';
+import { httpServerIntegration } from './httpServerIntegration';
+import type { HttpServerSpansIntegrationOptions } from './httpServerSpansIntegration';
+import { httpServerSpansIntegration } from './httpServerSpansIntegration';
import type { SentryHttpInstrumentationOptions } from './SentryHttpInstrumentation';
import { SentryHttpInstrumentation } from './SentryHttpInstrumentation';
@@ -79,6 +83,14 @@ interface HttpOptions {
*/
ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean;
+ /**
+ * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc.
+ * This helps reduce noise in your transactions.
+ *
+ * @default `true`
+ */
+ ignoreStaticAssets?: boolean;
+
/**
* Controls the maximum size of incoming HTTP request bodies attached to events.
*
@@ -114,52 +126,51 @@ export const instrumentSentryHttp = generateInstrumentOnce {
- const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [
- [401, 404],
- // 300 and 304 are possibly valid status codes we do not want to filter
- [301, 303],
- [305, 399],
- ];
+ const serverOptions: HttpServerIntegrationOptions = {
+ sessions: options.trackIncomingRequestsAsSessions,
+ sessionFlushingDelayMS: options.sessionFlushingDelayMS,
+ ignoreRequestBody: options.ignoreIncomingRequestBody,
+ maxRequestBodySize: options.maxIncomingRequestBodySize,
+ };
+
+ const serverSpansOptions: HttpServerSpansIntegrationOptions = {
+ ignoreIncomingRequests: options.ignoreIncomingRequests,
+ ignoreStaticAssets: options.ignoreStaticAssets,
+ ignoreStatusCodes: options.dropSpansForIncomingRequestStatusCodes,
+ };
+
+ const httpInstrumentationOptions: SentryHttpInstrumentationOptions = {
+ breadcrumbs: options.breadcrumbs,
+ propagateTraceInOutgoingRequests: true,
+ ignoreOutgoingRequests: options.ignoreOutgoingRequests,
+ };
+
+ const server = httpServerIntegration(serverOptions);
+ const serverSpans = httpServerSpansIntegration(serverSpansOptions);
+
+ // In node-core, for now we disable incoming requests spans by default
+ // we may revisit this in a future release
+ const spans = options.spans ?? false;
+ const disableIncomingRequestSpans = options.disableIncomingRequestSpans ?? false;
+ const enabledServerSpans = spans && !disableIncomingRequestSpans;
return {
name: INTEGRATION_NAME,
+ setup(client: NodeClient) {
+ if (enabledServerSpans) {
+ serverSpans.setup(client);
+ }
+ },
setupOnce() {
- instrumentSentryHttp({
- ...options,
- ignoreSpansForIncomingRequests: options.ignoreIncomingRequests,
- // TODO(v11): Rethink this, for now this is for backwards compatibility
- disableIncomingRequestSpans: true,
- propagateTraceInOutgoingRequests: true,
- });
+ server.setupOnce();
+
+ instrumentSentryHttp(httpInstrumentationOptions);
},
- processEvent(event) {
- // Drop transaction if it has a status code that should be ignored
- if (event.type === 'transaction') {
- const statusCode = event.contexts?.trace?.data?.['http.response.status_code'];
- if (typeof statusCode === 'number') {
- const shouldDrop = shouldFilterStatusCode(statusCode, dropSpansForIncomingRequestStatusCodes);
- if (shouldDrop) {
- DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode);
- return null;
- }
- }
- }
- return event;
+ processEvent(event) {
+ // Note: We always run this, even if spans are disabled
+ // The reason being that e.g. the remix integration disables span creation here but still wants to use the ignore status codes option
+ return serverSpans.processEvent(event);
},
};
});
-
-/**
- * If the given status code should be filtered for the given list of status codes/ranges.
- */
-function shouldFilterStatusCode(statusCode: number, dropForStatusCodes: (number | [number, number])[]): boolean {
- return dropForStatusCodes.some(code => {
- if (typeof code === 'number') {
- return code === statusCode;
- }
-
- const [min, max] = code;
- return statusCode >= min && statusCode <= max;
- });
-}
diff --git a/packages/node-core/test/integrations/request-session-tracking.test.ts b/packages/node-core/test/integrations/httpServerIntegration.test.ts
similarity index 98%
rename from packages/node-core/test/integrations/request-session-tracking.test.ts
rename to packages/node-core/test/integrations/httpServerIntegration.test.ts
index b7d7ec4f2354..555bc9fad16e 100644
--- a/packages/node-core/test/integrations/request-session-tracking.test.ts
+++ b/packages/node-core/test/integrations/httpServerIntegration.test.ts
@@ -2,7 +2,7 @@ import type { Client } from '@sentry/core';
import { createTransport, Scope, ServerRuntimeClient, withScope } from '@sentry/core';
import { EventEmitter } from 'stream';
import { describe, expect, it, vi } from 'vitest';
-import { recordRequestSession } from '../../src/integrations/http/incoming-requests';
+import { recordRequestSession } from '../../src/integrations/http/httpServerIntegration';
vi.useFakeTimers();
@@ -124,7 +124,7 @@ function simulateRequest(client: Client, status: 'ok' | 'errored' | 'crashed') {
const requestIsolationScope = new Scope();
const response = new EventEmitter();
- recordRequestSession({
+ recordRequestSession(client, {
requestIsolationScope,
response,
});
diff --git a/packages/node-core/test/integrations/http.test.ts b/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts
similarity index 97%
rename from packages/node-core/test/integrations/http.test.ts
rename to packages/node-core/test/integrations/httpServerSpansIntegration.test.ts
index 01124327a030..5603310db108 100644
--- a/packages/node-core/test/integrations/http.test.ts
+++ b/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { isStaticAssetRequest } from '../../src/integrations/http/incoming-requests';
+import { isStaticAssetRequest } from '../../src/integrations/http/httpServerSpansIntegration';
describe('httpIntegration', () => {
describe('isStaticAssetRequest', () => {
diff --git a/packages/node-core/tsconfig.json b/packages/node-core/tsconfig.json
index 64d6f3a1b9e0..07c7602c1fdd 100644
--- a/packages/node-core/tsconfig.json
+++ b/packages/node-core/tsconfig.json
@@ -4,7 +4,7 @@
"include": ["src/**/*"],
"compilerOptions": {
- "lib": ["es2020"],
+ "lib": ["ES2020", "ES2021.WeakRef"],
"module": "Node16"
}
}
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index 67e00660c2a1..4808f22b472b 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -157,6 +157,8 @@ export type {
export {
logger,
+ httpServerIntegration,
+ httpServerSpansIntegration,
nodeContextIntegration,
contextLinesIntegration,
localVariablesIntegration,
diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts
index dc7b48b4862c..3160898e0827 100644
--- a/packages/node/src/integrations/http.ts
+++ b/packages/node/src/integrations/http.ts
@@ -3,17 +3,18 @@ import { diag } from '@opentelemetry/api';
import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-http';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import type { Span } from '@sentry/core';
-import { debug, defineIntegration, getClient, hasSpansEnabled } from '@sentry/core';
+import { defineIntegration, getClient, hasSpansEnabled } from '@sentry/core';
import type { HTTPModuleRequestIncomingMessage, NodeClient } from '@sentry/node-core';
import {
type SentryHttpInstrumentationOptions,
addOriginToSpan,
generateInstrumentOnce,
getRequestUrl,
+ httpServerIntegration,
+ httpServerSpansIntegration,
NODE_VERSION,
SentryHttpInstrumentation,
} from '@sentry/node-core';
-import { DEBUG_BUILD } from '../debug-build';
import type { NodeClientOptions } from '../types';
const INTEGRATION_NAME = 'Http';
@@ -75,6 +76,12 @@ interface HttpOptions {
*/
ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean;
+ /**
+ * A hook that can be used to mutate the span for incoming requests.
+ * This is triggered after the span is created, but before it is recorded.
+ */
+ incomingRequestSpanHook?: (span: Span, request: IncomingMessage, response: ServerResponse) => void;
+
/**
* Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc.
* This helps reduce noise in your transactions.
@@ -194,50 +201,63 @@ export function _shouldUseOtelHttpInstrumentation(
* It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span.
*/
export const httpIntegration = defineIntegration((options: HttpOptions = {}) => {
- const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [
- [401, 404],
- // 300 and 304 are possibly valid status codes we do not want to filter
- [301, 303],
- [305, 399],
- ];
+ const spans = options.spans ?? true;
+ const disableIncomingRequestSpans = options.disableIncomingRequestSpans;
+
+ const serverOptions = {
+ sessions: options.trackIncomingRequestsAsSessions,
+ sessionFlushingDelayMS: options.sessionFlushingDelayMS,
+ ignoreRequestBody: options.ignoreIncomingRequestBody,
+ maxRequestBodySize: options.maxIncomingRequestBodySize,
+ } satisfies Parameters[0];
+
+ const serverSpansOptions = {
+ ignoreIncomingRequests: options.ignoreIncomingRequests,
+ ignoreStaticAssets: options.ignoreStaticAssets,
+ ignoreStatusCodes: options.dropSpansForIncomingRequestStatusCodes,
+ instrumentation: options.instrumentation,
+ onSpanCreated: options.incomingRequestSpanHook,
+ } satisfies Parameters[0];
+
+ const server = httpServerIntegration(serverOptions);
+ const serverSpans = httpServerSpansIntegration(serverSpansOptions);
+
+ const enableServerSpans = spans && !disableIncomingRequestSpans;
return {
name: INTEGRATION_NAME,
+ setup(client: NodeClient) {
+ const clientOptions = client.getOptions();
+
+ if (enableServerSpans && hasSpansEnabled(clientOptions)) {
+ serverSpans.setup(client);
+ }
+ },
setupOnce() {
- const clientOptions = (getClient()?.getOptions() || {}) as Partial;
+ const clientOptions = (getClient()?.getOptions() || {}) satisfies Partial;
const useOtelHttpInstrumentation = _shouldUseOtelHttpInstrumentation(options, clientOptions);
- const disableIncomingRequestSpans = options.disableIncomingRequestSpans ?? !hasSpansEnabled(clientOptions);
-
- // This is Sentry-specific instrumentation for request isolation and breadcrumbs
- instrumentSentryHttp({
- ...options,
- disableIncomingRequestSpans,
- ignoreSpansForIncomingRequests: options.ignoreIncomingRequests,
- // If spans are not instrumented, it means the HttpInstrumentation has not been added
- // In that case, we want to handle trace propagation ourselves
+
+ server.setupOnce();
+
+ const sentryHttpInstrumentationOptions = {
+ breadcrumbs: options.breadcrumbs,
propagateTraceInOutgoingRequests: !useOtelHttpInstrumentation,
- });
+ ignoreOutgoingRequests: options.ignoreOutgoingRequests,
+ } satisfies SentryHttpInstrumentationOptions;
+
+ // This is Sentry-specific instrumentation for outgoing request breadcrumbs & trace propagation
+ instrumentSentryHttp(sentryHttpInstrumentationOptions);
- // This is the "regular" OTEL instrumentation that emits spans
+ // This is the "regular" OTEL instrumentation that emits outgoing request spans
if (useOtelHttpInstrumentation) {
const instrumentationConfig = getConfigWithDefaults(options);
instrumentOtelHttp(instrumentationConfig);
}
},
processEvent(event) {
- // Drop transaction if it has a status code that should be ignored
- if (event.type === 'transaction') {
- const statusCode = event.contexts?.trace?.data?.['http.response.status_code'];
- if (typeof statusCode === 'number') {
- const shouldDrop = shouldFilterStatusCode(statusCode, dropSpansForIncomingRequestStatusCodes);
- if (shouldDrop) {
- DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode);
- return null;
- }
- }
- }
-
- return event;
+ // Note: We always run this, even if spans are disabled
+ // The reason being that e.g. the remix integration disables span creation here but still wants to use the ignore status codes option
+ return serverSpans.processEvent(event);
},
};
});
@@ -279,17 +299,3 @@ function getConfigWithDefaults(options: Partial = {}): HttpInstrume
return instrumentationConfig;
}
-
-/**
- * If the given status code should be filtered for the given list of status codes/ranges.
- */
-function shouldFilterStatusCode(statusCode: number, dropForStatusCodes: (number | [number, number])[]): boolean {
- return dropForStatusCodes.some(code => {
- if (typeof code === 'number') {
- return code === statusCode;
- }
-
- const [min, max] = code;
- return statusCode >= min && statusCode <= max;
- });
-}
diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts
index 5e1343b1ebaa..7e9445a154a7 100644
--- a/packages/nuxt/src/module.ts
+++ b/packages/nuxt/src/module.ts
@@ -10,6 +10,7 @@ import { consoleSandbox } from '@sentry/core';
import * as path from 'path';
import type { SentryNuxtModuleOptions } from './common/types';
import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig';
+import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig';
import { setupSourceMaps } from './vite/sourceMaps';
import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils';
@@ -110,7 +111,16 @@ export default defineNuxtModule({
};
});
+ // Preps the the middleware instrumentation module.
+ if (serverConfigFile) {
+ addMiddlewareImports();
+ }
+
nuxt.hooks.hook('nitro:init', nitro => {
+ if (serverConfigFile) {
+ addMiddlewareInstrumentation(nitro);
+ }
+
if (serverConfigFile?.includes('.server.config')) {
consoleSandbox(() => {
const serverDir = nitro.options.output.serverDir;
diff --git a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts
new file mode 100644
index 000000000000..a04b866cd774
--- /dev/null
+++ b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts
@@ -0,0 +1,193 @@
+import {
+ type SpanAttributes,
+ captureException,
+ debug,
+ flushIfServerless,
+ getClient,
+ httpHeadersToSpanAttributes,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ SPAN_STATUS_ERROR,
+ SPAN_STATUS_OK,
+ startSpan,
+} from '@sentry/core';
+import type {
+ _ResponseMiddleware as ResponseMiddleware,
+ EventHandler,
+ EventHandlerObject,
+ EventHandlerRequest,
+ EventHandlerResponse,
+ H3Event,
+} from 'h3';
+
+/**
+ * Wraps a middleware handler with Sentry instrumentation.
+ *
+ * @param handler The middleware handler.
+ * @param fileName The name of the middleware file.
+ */
+export function wrapMiddlewareHandlerWithSentry(
+ handler: THandler,
+ fileName: string,
+): THandler {
+ if (!isEventHandlerObject(handler)) {
+ return wrapEventHandler(handler, fileName) as THandler;
+ }
+
+ const handlerObj = {
+ ...handler,
+ handler: wrapEventHandler(handler.handler, fileName),
+ };
+
+ if (handlerObj.onRequest) {
+ handlerObj.onRequest = normalizeHandlers(handlerObj.onRequest, (h, index) =>
+ wrapEventHandler(h, fileName, 'onRequest', index),
+ );
+ }
+
+ if (handlerObj.onBeforeResponse) {
+ handlerObj.onBeforeResponse = normalizeHandlers(handlerObj.onBeforeResponse, (h, index) =>
+ wrapResponseHandler(h, fileName, index),
+ );
+ }
+
+ return handlerObj;
+}
+
+/**
+ * Wraps a callable event handler with Sentry instrumentation.
+ *
+ * @param handler The event handler.
+ * @param handlerName The name of the event handler to be used for the span name and logging.
+ */
+function wrapEventHandler(
+ handler: EventHandler,
+ middlewareName: string,
+ hookName?: 'onRequest',
+ index?: number,
+): EventHandler {
+ return async (event: H3Event) => {
+ debug.log(`Sentry middleware: ${middlewareName}${hookName ? `.${hookName}` : ''} handling ${event.path}`);
+
+ const attributes = getSpanAttributes(event, middlewareName, hookName, index);
+
+ return withSpan(() => handler(event), attributes, middlewareName, hookName);
+ };
+}
+
+/**
+ * Wraps a middleware response handler with Sentry instrumentation.
+ */
+function wrapResponseHandler(handler: ResponseMiddleware, middlewareName: string, index?: number): ResponseMiddleware {
+ return async (event: H3Event, response: EventHandlerResponse) => {
+ debug.log(`Sentry middleware: ${middlewareName}.onBeforeResponse handling ${event.path}`);
+
+ const attributes = getSpanAttributes(event, middlewareName, 'onBeforeResponse', index);
+
+ return withSpan(() => handler(event, response), attributes, middlewareName, 'onBeforeResponse');
+ };
+}
+
+/**
+ * Wraps a middleware or event handler execution with a span.
+ */
+function withSpan(
+ handler: () => TResult | Promise,
+ attributes: SpanAttributes,
+ middlewareName: string,
+ hookName?: 'handler' | 'onRequest' | 'onBeforeResponse',
+): Promise {
+ const spanName = hookName && hookName !== 'handler' ? `${middlewareName}.${hookName}` : middlewareName;
+
+ return startSpan(
+ {
+ name: spanName,
+ attributes,
+ },
+ async span => {
+ try {
+ const result = await handler();
+ span.setStatus({ code: SPAN_STATUS_OK });
+
+ return result;
+ } catch (error) {
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
+ captureException(error, {
+ mechanism: {
+ handled: false,
+ type: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN],
+ },
+ });
+
+ // Re-throw the error to be handled by the caller
+ throw error;
+ } finally {
+ await flushIfServerless();
+ }
+ },
+ );
+}
+
+/**
+ * Takes a list of handlers and wraps them with the normalizer function.
+ */
+function normalizeHandlers(
+ handlers: T | T[],
+ normalizer: (h: T, index?: number) => T,
+): T | T[] {
+ return Array.isArray(handlers) ? handlers.map((handler, index) => normalizer(handler, index)) : normalizer(handlers);
+}
+
+/**
+ * Gets the span attributes for the middleware handler based on the event.
+ */
+function getSpanAttributes(
+ event: H3Event,
+ middlewareName: string,
+ hookName?: 'handler' | 'onRequest' | 'onBeforeResponse',
+ index?: number,
+): SpanAttributes {
+ const attributes: SpanAttributes = {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nuxt',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nuxt',
+ 'nuxt.middleware.name': middlewareName,
+ 'nuxt.middleware.hook.name': hookName ?? 'handler',
+ };
+
+ // Add index for array handlers
+ if (typeof index === 'number') {
+ attributes['nuxt.middleware.hook.index'] = index;
+ }
+
+ // Add HTTP method
+ if (event.method) {
+ attributes['http.request.method'] = event.method;
+ }
+
+ // Add route information
+ if (event.path) {
+ attributes['http.route'] = event.path;
+ }
+
+ // Extract and add HTTP headers as span attributes
+ const client = getClient();
+ const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false;
+
+ // Get headers from the Node.js request object
+ const headers = event.node?.req?.headers || {};
+ const headerAttributes = httpHeadersToSpanAttributes(headers, sendDefaultPii);
+
+ // Merge header attributes with existing attributes
+ Object.assign(attributes, headerAttributes);
+
+ return attributes;
+}
+
+/**
+ * Checks if the handler is an event handler, util for type narrowing.
+ */
+function isEventHandlerObject(handler: EventHandler | EventHandlerObject): handler is EventHandlerObject {
+ return typeof handler !== 'function';
+}
diff --git a/packages/nuxt/src/vite/middlewareConfig.ts b/packages/nuxt/src/vite/middlewareConfig.ts
new file mode 100644
index 000000000000..d851345172d8
--- /dev/null
+++ b/packages/nuxt/src/vite/middlewareConfig.ts
@@ -0,0 +1,97 @@
+import { addServerImports, createResolver } from '@nuxt/kit';
+import type { Nitro } from 'nitropack/types';
+import * as path from 'path';
+import type { InputPluginOption } from 'rollup';
+
+/**
+ * Adds a server import for the middleware instrumentation.
+ */
+export function addMiddlewareImports(): void {
+ addServerImports([
+ {
+ name: 'wrapMiddlewareHandlerWithSentry',
+ from: createResolver(import.meta.url).resolve('./runtime/hooks/wrapMiddlewareHandler'),
+ },
+ ]);
+}
+
+/**
+ * Adds middleware instrumentation to the Nitro build.
+ *
+ * @param nitro Nitro instance
+ */
+export function addMiddlewareInstrumentation(nitro: Nitro): void {
+ nitro.hooks.hook('rollup:before', (nitro, rollupConfig) => {
+ if (!rollupConfig.plugins) {
+ rollupConfig.plugins = [];
+ }
+
+ if (!Array.isArray(rollupConfig.plugins)) {
+ rollupConfig.plugins = [rollupConfig.plugins];
+ }
+
+ rollupConfig.plugins.push(middlewareInstrumentationPlugin(nitro));
+ });
+}
+
+/**
+ * Creates a rollup plugin for the middleware instrumentation by transforming the middleware code.
+ *
+ * @param nitro Nitro instance
+ * @returns The rollup plugin for the middleware instrumentation.
+ */
+function middlewareInstrumentationPlugin(nitro: Nitro): InputPluginOption {
+ const middlewareFiles = new Set();
+
+ return {
+ name: 'sentry-nuxt-middleware-instrumentation',
+ buildStart() {
+ // Collect middleware files during build start
+ nitro.scannedHandlers?.forEach(({ middleware, handler }) => {
+ if (middleware && handler) {
+ middlewareFiles.add(handler);
+ }
+ });
+ },
+ transform(code: string, id: string) {
+ // Only transform files we've identified as middleware
+ if (middlewareFiles.has(id)) {
+ const fileName = path.basename(id);
+ return {
+ code: wrapMiddlewareCode(code, fileName),
+ map: null,
+ };
+ }
+ return null;
+ },
+ };
+}
+
+/**
+ * Wraps the middleware user code to instrument it.
+ *
+ * @param originalCode The original user code of the middleware.
+ * @param fileName The name of the middleware file, used for the span name and logging.
+ *
+ * @returns The wrapped user code of the middleware.
+ */
+function wrapMiddlewareCode(originalCode: string, fileName: string): string {
+ // Remove common file extensions
+ const cleanFileName = fileName.replace(/\.(ts|js|mjs|mts|cts)$/, '');
+
+ return `
+import { wrapMiddlewareHandlerWithSentry } from '#imports';
+
+function defineInstrumentedEventHandler(handlerOrObject) {
+ return defineEventHandler(wrapMiddlewareHandlerWithSentry(handlerOrObject, '${cleanFileName}'));
+}
+
+function instrumentedEventHandler(handlerOrObject) {
+ return eventHandler(wrapMiddlewareHandlerWithSentry(handlerOrObject, '${cleanFileName}'));
+}
+
+${originalCode
+ .replace(/defineEventHandler\(/g, 'defineInstrumentedEventHandler(')
+ .replace(/eventHandler\(/g, 'instrumentedEventHandler(')}
+`;
+}
diff --git a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts
new file mode 100644
index 000000000000..c1f73cd858fa
--- /dev/null
+++ b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts
@@ -0,0 +1,506 @@
+import * as SentryCore from '@sentry/core';
+import type { EventHandler, EventHandlerRequest, H3Event } from 'h3';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { wrapMiddlewareHandlerWithSentry } from '../../../src/runtime/hooks/wrapMiddlewareHandler';
+
+// Only mock the Sentry APIs we need to verify
+vi.mock('@sentry/core', async importOriginal => {
+ const mod = await importOriginal();
+ return {
+ ...(mod as any),
+ debug: { log: vi.fn() },
+ startSpan: vi.fn(),
+ getClient: vi.fn(),
+ httpHeadersToSpanAttributes: vi.fn(),
+ captureException: vi.fn(),
+ flushIfServerless: vi.fn(),
+ };
+});
+
+describe('wrapMiddlewareHandlerWithSentry', () => {
+ const mockEvent: H3Event = {
+ path: '/test-path',
+ method: 'GET',
+ node: {
+ req: {
+ headers: { 'user-agent': 'test-agent' },
+ url: '/test-url',
+ },
+ },
+ } as any;
+
+ const mockSpan = {
+ setStatus: vi.fn(),
+ recordException: vi.fn(),
+ end: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Setup minimal required mocks
+ (SentryCore.startSpan as any).mockImplementation((_config: any, callback: any) => callback(mockSpan));
+ (SentryCore.getClient as any).mockReturnValue({ getOptions: () => ({ sendDefaultPii: false }) });
+ (SentryCore.httpHeadersToSpanAttributes as any).mockReturnValue({ 'http.request.header.user_agent': 'test-agent' });
+ (SentryCore.flushIfServerless as any).mockResolvedValue(undefined);
+ });
+
+ describe('function handler wrapping', () => {
+ it('should wrap function handlers correctly and preserve return values', async () => {
+ const functionHandler: EventHandler = vi.fn().mockResolvedValue('success');
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(functionHandler, 'test-middleware');
+ const result = await wrapped(mockEvent);
+
+ expect(functionHandler).toHaveBeenCalledWith(mockEvent);
+ expect(result).toBe('success');
+ expect(typeof wrapped).toBe('function');
+ });
+
+ it('should preserve sync return values from function handlers', async () => {
+ const syncHandler: EventHandler = vi.fn().mockReturnValue('sync-result');
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(syncHandler, 'sync-middleware');
+ const result = await wrapped(mockEvent);
+
+ expect(syncHandler).toHaveBeenCalledWith(mockEvent);
+ expect(result).toBe('sync-result');
+ });
+ });
+
+ describe('different handler types', () => {
+ it('should handle async function handlers', async () => {
+ const asyncHandler: EventHandler = vi.fn().mockResolvedValue('async-success');
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(asyncHandler, 'async-middleware');
+ const result = await wrapped(mockEvent);
+
+ expect(asyncHandler).toHaveBeenCalledWith(mockEvent);
+ expect(result).toBe('async-success');
+ });
+ });
+
+ describe('error propagation without masking', () => {
+ it('should propagate async errors without modification', async () => {
+ const originalError = new Error('Original async error');
+ originalError.stack = 'original-stack-trace';
+ const failingHandler: EventHandler = vi.fn().mockRejectedValue(originalError);
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(failingHandler, 'failing-middleware');
+
+ await expect(wrapped(mockEvent)).rejects.toThrow('Original async error');
+ await expect(wrapped(mockEvent)).rejects.toMatchObject({
+ message: 'Original async error',
+ stack: 'original-stack-trace',
+ });
+
+ // Verify Sentry APIs were called but error was not masked
+ expect(SentryCore.captureException).toHaveBeenCalledWith(originalError, expect.any(Object));
+ });
+
+ it('should propagate sync errors without modification', async () => {
+ const originalError = new Error('Original sync error');
+ const failingHandler: EventHandler = vi.fn().mockImplementation(() => {
+ throw originalError;
+ });
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(failingHandler, 'sync-failing-middleware');
+
+ await expect(wrapped(mockEvent)).rejects.toThrow('Original sync error');
+ await expect(wrapped(mockEvent)).rejects.toBe(originalError);
+
+ expect(SentryCore.captureException).toHaveBeenCalledWith(originalError, expect.any(Object));
+ });
+
+ it('should handle non-Error thrown values', async () => {
+ const stringError = 'String error';
+ const failingHandler: EventHandler = vi.fn().mockRejectedValue(stringError);
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(failingHandler, 'string-error-middleware');
+
+ await expect(wrapped(mockEvent)).rejects.toBe(stringError);
+ expect(SentryCore.captureException).toHaveBeenCalledWith(stringError, expect.any(Object));
+ });
+ });
+
+ describe('user code isolation', () => {
+ it('should not affect user code when Sentry APIs fail', async () => {
+ // Simulate Sentry API failures
+ (SentryCore.startSpan as any).mockImplementation(() => {
+ throw new Error('Sentry API failure');
+ });
+
+ const userHandler: EventHandler = vi.fn().mockResolvedValue('user-result');
+
+ // Should not throw despite Sentry failure
+ const wrapped = wrapMiddlewareHandlerWithSentry(userHandler, 'isolated-middleware');
+
+ // This should handle the Sentry error gracefully and still call user code
+ await expect(wrapped(mockEvent)).rejects.toThrow('Sentry API failure');
+ });
+ });
+
+ describe('EventHandlerObject wrapping', () => {
+ it('should wrap EventHandlerObject.handler correctly', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('handler-result');
+ const handlerObject = {
+ handler: baseHandler,
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'object-middleware');
+
+ // Should return an object with wrapped handler
+ expect(typeof wrapped).toBe('object');
+ expect(wrapped).toHaveProperty('handler');
+ expect(typeof wrapped.handler).toBe('function');
+
+ // Test that the wrapped handler works
+ const result = await wrapped.handler(mockEvent);
+ expect(result).toBe('handler-result');
+ expect(baseHandler).toHaveBeenCalledWith(mockEvent);
+
+ // Verify Sentry instrumentation was applied
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'object-middleware',
+ attributes: expect.objectContaining({
+ [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nuxt',
+ 'nuxt.middleware.name': 'object-middleware',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('should wrap EventHandlerObject.onRequest handlers correctly', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('main-result');
+ const onRequestHandler = vi.fn().mockResolvedValue(undefined);
+ const handlerObject = {
+ handler: baseHandler,
+ onRequest: onRequestHandler,
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'request-middleware');
+
+ // Should preserve onRequest handler
+ expect(wrapped).toHaveProperty('onRequest');
+ expect(typeof wrapped.onRequest).toBe('function');
+
+ // Test that the wrapped onRequest handler works
+ await wrapped.onRequest(mockEvent);
+ expect(onRequestHandler).toHaveBeenCalledWith(mockEvent);
+
+ // Verify Sentry instrumentation was applied to onRequest
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'request-middleware.onRequest',
+ attributes: expect.objectContaining({
+ [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nuxt',
+ 'nuxt.middleware.name': 'request-middleware',
+ 'nuxt.middleware.hook.name': 'onRequest',
+ }),
+ }),
+ expect.any(Function),
+ );
+
+ // Verify that single handlers don't have an index attribute
+ const spanCall = (SentryCore.startSpan as any).mock.calls.find(
+ (call: any) => call[0]?.attributes?.['nuxt.middleware.hook.name'] === 'onRequest',
+ );
+ expect(spanCall[0].attributes).not.toHaveProperty('nuxt.middleware.hook.index');
+ });
+
+ it('should wrap EventHandlerObject.onRequest array of handlers correctly', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('main-result');
+ const onRequestHandler1 = vi.fn().mockResolvedValue(undefined);
+ const onRequestHandler2 = vi.fn().mockResolvedValue(undefined);
+ const handlerObject = {
+ handler: baseHandler,
+ onRequest: [onRequestHandler1, onRequestHandler2],
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'multi-request-middleware');
+
+ // Should preserve onRequest as array
+ expect(wrapped).toHaveProperty('onRequest');
+ expect(Array.isArray(wrapped.onRequest)).toBe(true);
+ expect(wrapped.onRequest).toHaveLength(2);
+
+ // Test that both wrapped handlers work
+ if (Array.isArray(wrapped.onRequest)) {
+ await wrapped.onRequest[0]!(mockEvent);
+ await wrapped.onRequest[1]!(mockEvent);
+ }
+
+ expect(onRequestHandler1).toHaveBeenCalledWith(mockEvent);
+ expect(onRequestHandler2).toHaveBeenCalledWith(mockEvent);
+
+ // Verify Sentry instrumentation was applied to both handlers
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'multi-request-middleware.onRequest',
+ attributes: expect.objectContaining({
+ 'nuxt.middleware.hook.name': 'onRequest',
+ 'nuxt.middleware.hook.index': 0,
+ }),
+ }),
+ expect.any(Function),
+ );
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'multi-request-middleware.onRequest',
+ attributes: expect.objectContaining({
+ 'nuxt.middleware.hook.name': 'onRequest',
+ 'nuxt.middleware.hook.index': 1,
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('should wrap EventHandlerObject.onBeforeResponse handlers correctly', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('main-result');
+ const onBeforeResponseHandler = vi.fn().mockResolvedValue(undefined);
+ const handlerObject = {
+ handler: baseHandler,
+ onBeforeResponse: onBeforeResponseHandler,
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'response-middleware');
+
+ // Should preserve onBeforeResponse handler
+ expect(wrapped).toHaveProperty('onBeforeResponse');
+ expect(typeof wrapped.onBeforeResponse).toBe('function');
+
+ // Test that the wrapped onBeforeResponse handler works
+ const mockResponse = { body: 'test-response' };
+ await wrapped.onBeforeResponse(mockEvent, mockResponse);
+ expect(onBeforeResponseHandler).toHaveBeenCalledWith(mockEvent, mockResponse);
+
+ // Verify Sentry instrumentation was applied to onBeforeResponse
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'response-middleware.onBeforeResponse',
+ attributes: expect.objectContaining({
+ [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nuxt',
+ 'nuxt.middleware.name': 'response-middleware',
+ 'nuxt.middleware.hook.name': 'onBeforeResponse',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('should wrap EventHandlerObject.onBeforeResponse array of handlers correctly', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('main-result');
+ const onBeforeResponseHandler1 = vi.fn().mockResolvedValue(undefined);
+ const onBeforeResponseHandler2 = vi.fn().mockResolvedValue(undefined);
+ const handlerObject = {
+ handler: baseHandler,
+ onBeforeResponse: [onBeforeResponseHandler1, onBeforeResponseHandler2],
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'multi-response-middleware');
+
+ // Should preserve onBeforeResponse as array
+ expect(wrapped).toHaveProperty('onBeforeResponse');
+ expect(Array.isArray(wrapped.onBeforeResponse)).toBe(true);
+ expect(wrapped.onBeforeResponse).toHaveLength(2);
+
+ // Test that both wrapped handlers work
+ const mockResponse = { body: 'test-response' };
+ if (Array.isArray(wrapped.onBeforeResponse)) {
+ await wrapped.onBeforeResponse[0]!(mockEvent, mockResponse);
+ await wrapped.onBeforeResponse[1]!(mockEvent, mockResponse);
+ }
+
+ expect(onBeforeResponseHandler1).toHaveBeenCalledWith(mockEvent, mockResponse);
+ expect(onBeforeResponseHandler2).toHaveBeenCalledWith(mockEvent, mockResponse);
+
+ // Verify Sentry instrumentation was applied to both handlers
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'multi-response-middleware.onBeforeResponse',
+ attributes: expect.objectContaining({
+ 'nuxt.middleware.hook.name': 'onBeforeResponse',
+ 'nuxt.middleware.hook.index': 0,
+ }),
+ }),
+ expect.any(Function),
+ );
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'multi-response-middleware.onBeforeResponse',
+ attributes: expect.objectContaining({
+ 'nuxt.middleware.hook.name': 'onBeforeResponse',
+ 'nuxt.middleware.hook.index': 1,
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('should wrap complex EventHandlerObject with all properties', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('complex-result');
+ const onRequestHandler = vi.fn().mockResolvedValue(undefined);
+ const onBeforeResponseHandler = vi.fn().mockResolvedValue(undefined);
+ const handlerObject = {
+ handler: baseHandler,
+ onRequest: onRequestHandler,
+ onBeforeResponse: onBeforeResponseHandler,
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'complex-middleware');
+
+ // Should preserve all properties
+ expect(wrapped).toHaveProperty('handler');
+ expect(wrapped).toHaveProperty('onRequest');
+ expect(wrapped).toHaveProperty('onBeforeResponse');
+
+ // Test main handler
+ const result = await wrapped.handler(mockEvent);
+ expect(result).toBe('complex-result');
+ expect(baseHandler).toHaveBeenCalledWith(mockEvent);
+
+ // Test onRequest handler
+ await wrapped.onRequest(mockEvent);
+ expect(onRequestHandler).toHaveBeenCalledWith(mockEvent);
+
+ // Test onBeforeResponse handler
+ const mockResponse = { body: 'test-response' };
+ await wrapped.onBeforeResponse(mockEvent, mockResponse);
+ expect(onBeforeResponseHandler).toHaveBeenCalledWith(mockEvent, mockResponse);
+
+ // Verify all handlers got Sentry instrumentation
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'complex-middleware',
+ attributes: expect.objectContaining({ 'nuxt.middleware.hook.name': 'handler' }),
+ }),
+ expect.any(Function),
+ );
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'complex-middleware.onRequest',
+ attributes: expect.objectContaining({ 'nuxt.middleware.hook.name': 'onRequest' }),
+ }),
+ expect.any(Function),
+ );
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'complex-middleware.onBeforeResponse',
+ attributes: expect.objectContaining({ 'nuxt.middleware.hook.name': 'onBeforeResponse' }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('should handle EventHandlerObject without optional handlers', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('minimal-object-result');
+ const handlerObject = {
+ handler: baseHandler,
+ // No onRequest or onBeforeResponse
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'minimal-object-middleware');
+
+ // Should only have handler property
+ expect(wrapped).toHaveProperty('handler');
+ expect(wrapped).not.toHaveProperty('onRequest');
+ expect(wrapped).not.toHaveProperty('onBeforeResponse');
+
+ // Test that the main handler works
+ const result = await wrapped.handler(mockEvent);
+ expect(result).toBe('minimal-object-result');
+ expect(baseHandler).toHaveBeenCalledWith(mockEvent);
+
+ // Verify Sentry instrumentation was applied
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'minimal-object-middleware',
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('should propagate errors from EventHandlerObject.handler', async () => {
+ const error = new Error('Handler error');
+ const failingHandler: EventHandler = vi.fn().mockRejectedValue(error);
+ const handlerObject = {
+ handler: failingHandler,
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'failing-object-middleware');
+
+ await expect(wrapped.handler(mockEvent)).rejects.toThrow('Handler error');
+ expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.any(Object));
+ });
+
+ it('should propagate errors from EventHandlerObject.onRequest', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('success');
+ const error = new Error('OnRequest error');
+ const failingOnRequestHandler = vi.fn().mockRejectedValue(error);
+ const handlerObject = {
+ handler: baseHandler,
+ onRequest: failingOnRequestHandler,
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'failing-request-middleware');
+
+ await expect(wrapped.onRequest(mockEvent)).rejects.toThrow('OnRequest error');
+ expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.any(Object));
+ });
+
+ it('should propagate errors from EventHandlerObject.onBeforeResponse', async () => {
+ const baseHandler: EventHandler = vi.fn().mockResolvedValue('success');
+ const error = new Error('OnBeforeResponse error');
+ const failingOnBeforeResponseHandler = vi.fn().mockRejectedValue(error);
+ const handlerObject = {
+ handler: baseHandler,
+ onBeforeResponse: failingOnBeforeResponseHandler,
+ };
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject, 'failing-response-middleware');
+
+ const mockResponse = { body: 'test-response' };
+ await expect(wrapped.onBeforeResponse(mockEvent, mockResponse)).rejects.toThrow('OnBeforeResponse error');
+ expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.any(Object));
+ });
+ });
+
+ describe('Sentry API integration', () => {
+ it('should call Sentry APIs with correct parameters', async () => {
+ const userHandler: EventHandler = vi.fn().mockResolvedValue('api-test-result');
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(userHandler, 'api-middleware');
+ await wrapped(mockEvent);
+
+ // Verify key Sentry APIs are called correctly
+ expect(SentryCore.startSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'api-middleware',
+ attributes: expect.objectContaining({
+ [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nuxt',
+ 'nuxt.middleware.name': 'api-middleware',
+ 'http.request.method': 'GET',
+ 'http.route': '/test-path',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('should handle missing optional data gracefully', async () => {
+ const minimalEvent = { path: '/minimal' } as H3Event;
+ const userHandler: EventHandler = vi.fn().mockResolvedValue('minimal-result');
+
+ const wrapped = wrapMiddlewareHandlerWithSentry(userHandler, 'minimal-middleware');
+ const result = await wrapped(minimalEvent);
+
+ expect(result).toBe('minimal-result');
+ expect(userHandler).toHaveBeenCalledWith(minimalEvent);
+ // Should still create span even with minimal data
+ expect(SentryCore.startSpan).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts
index ef25e4b703e6..9c9885d1749a 100644
--- a/packages/remix/src/server/index.ts
+++ b/packages/remix/src/server/index.ts
@@ -48,6 +48,8 @@ export {
graphqlIntegration,
hapiIntegration,
httpIntegration,
+ httpServerIntegration,
+ httpServerSpansIntegration,
// eslint-disable-next-line deprecation/deprecation
inboundFiltersIntegration,
eventFiltersIntegration,
diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts
index d1ad987da56f..db470feb3039 100644
--- a/packages/solidstart/src/server/index.ts
+++ b/packages/solidstart/src/server/index.ts
@@ -52,6 +52,8 @@ export {
graphqlIntegration,
hapiIntegration,
httpIntegration,
+ httpServerIntegration,
+ httpServerSpansIntegration,
// eslint-disable-next-line deprecation/deprecation
inboundFiltersIntegration,
eventFiltersIntegration,
diff --git a/yarn.lock b/yarn.lock
index 6e477bf0a40b..8c030924b033 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1583,7 +1583,7 @@
dependencies:
"@babel/types" "^7.26.9"
-"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.20.7", "@babel/parser@^7.21.8", "@babel/parser@^7.22.10", "@babel/parser@^7.22.16", "@babel/parser@^7.22.5", "@babel/parser@^7.23.5", "@babel/parser@^7.23.6", "@babel/parser@^7.23.9", "@babel/parser@^7.25.3", "@babel/parser@^7.25.4", "@babel/parser@^7.25.6", "@babel/parser@^7.26.7", "@babel/parser@^7.27.2", "@babel/parser@^7.27.5", "@babel/parser@^7.27.7", "@babel/parser@^7.28.3", "@babel/parser@^7.4.5", "@babel/parser@^7.7.0":
+"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.20.7", "@babel/parser@^7.21.8", "@babel/parser@^7.22.10", "@babel/parser@^7.22.16", "@babel/parser@^7.22.5", "@babel/parser@^7.23.5", "@babel/parser@^7.23.6", "@babel/parser@^7.23.9", "@babel/parser@^7.25.3", "@babel/parser@^7.25.4", "@babel/parser@^7.25.6", "@babel/parser@^7.26.7", "@babel/parser@^7.27.2", "@babel/parser@^7.27.5", "@babel/parser@^7.27.7", "@babel/parser@^7.28.4", "@babel/parser@^7.4.5", "@babel/parser@^7.7.0":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8"
integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==
@@ -9371,13 +9371,13 @@
estree-walker "^2.0.2"
source-map "^0.6.1"
-"@vue/compiler-core@3.5.21":
- version "3.5.21"
- resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.21.tgz#5915b19273f0492336f0beb227aba86813e2c8a8"
- integrity sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==
+"@vue/compiler-core@3.5.22":
+ version "3.5.22"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.22.tgz#bb8294a0dd31df540563cc6ffa0456f1f7687b97"
+ integrity sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==
dependencies:
- "@babel/parser" "^7.28.3"
- "@vue/shared" "3.5.21"
+ "@babel/parser" "^7.28.4"
+ "@vue/shared" "3.5.22"
entities "^4.5.0"
estree-walker "^2.0.2"
source-map-js "^1.2.1"
@@ -9401,13 +9401,13 @@
"@vue/compiler-core" "3.2.45"
"@vue/shared" "3.2.45"
-"@vue/compiler-dom@3.5.21", "@vue/compiler-dom@^3.3.4":
- version "3.5.21"
- resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz#26126447fe1e1d16c8cbac45b26e66b3f7175f65"
- integrity sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==
+"@vue/compiler-dom@3.5.22", "@vue/compiler-dom@^3.3.4":
+ version "3.5.22"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz#6c9c2c9843520f6d3dbc685e5d0e1e12a2c04c56"
+ integrity sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==
dependencies:
- "@vue/compiler-core" "3.5.21"
- "@vue/shared" "3.5.21"
+ "@vue/compiler-core" "3.5.22"
+ "@vue/shared" "3.5.22"
"@vue/compiler-dom@3.5.9":
version "3.5.9"
@@ -9448,18 +9448,18 @@
postcss "^8.4.47"
source-map-js "^1.2.0"
-"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.4":
- version "3.5.21"
- resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz#e48189ef3ffe334c864c2625389ebe3bb4fa41eb"
- integrity sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==
+"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.13", "@vue/compiler-sfc@^3.5.4":
+ version "3.5.22"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz#663a8483b1dda8de83b6fa1aab38a52bf73dd965"
+ integrity sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==
dependencies:
- "@babel/parser" "^7.28.3"
- "@vue/compiler-core" "3.5.21"
- "@vue/compiler-dom" "3.5.21"
- "@vue/compiler-ssr" "3.5.21"
- "@vue/shared" "3.5.21"
+ "@babel/parser" "^7.28.4"
+ "@vue/compiler-core" "3.5.22"
+ "@vue/compiler-dom" "3.5.22"
+ "@vue/compiler-ssr" "3.5.22"
+ "@vue/shared" "3.5.22"
estree-walker "^2.0.2"
- magic-string "^0.30.18"
+ magic-string "^0.30.19"
postcss "^8.5.6"
source-map-js "^1.2.1"
@@ -9471,13 +9471,13 @@
"@vue/compiler-dom" "3.2.45"
"@vue/shared" "3.2.45"
-"@vue/compiler-ssr@3.5.21":
- version "3.5.21"
- resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz#f351c27aa5c075faa609596b2269c53df0df3aa1"
- integrity sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==
+"@vue/compiler-ssr@3.5.22":
+ version "3.5.22"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz#a0ef16e364731b25e79a13470569066af101320f"
+ integrity sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==
dependencies:
- "@vue/compiler-dom" "3.5.21"
- "@vue/shared" "3.5.21"
+ "@vue/compiler-dom" "3.5.22"
+ "@vue/shared" "3.5.22"
"@vue/compiler-ssr@3.5.9":
version "3.5.9"
@@ -9618,10 +9618,10 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.45.tgz#a3fffa7489eafff38d984e23d0236e230c818bc2"
integrity sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==
-"@vue/shared@3.5.21", "@vue/shared@^3.5.5":
- version "3.5.21"
- resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.21.tgz#505edb122629d1979f70a2a65ca0bd4050dc2e54"
- integrity sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==
+"@vue/shared@3.5.22", "@vue/shared@^3.5.5":
+ version "3.5.22"
+ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.22.tgz#9d56a1644a3becb8af1e34655928b0e288d827f8"
+ integrity sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==
"@vue/shared@3.5.9":
version "3.5.9"
@@ -14248,6 +14248,9 @@ detective-scss@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7"
integrity sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg==
+ dependencies:
+ gonzales-pe "^4.3.0"
+ node-source-walk "^7.0.1"
detective-stylus@^4.0.0:
version "4.0.0"
@@ -14282,6 +14285,14 @@ detective-vue2@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/detective-vue2/-/detective-vue2-2.2.0.tgz#35fd1d39e261b064aca9fcaf20e136c76877482a"
integrity sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==
+ dependencies:
+ "@dependents/detective-less" "^5.0.1"
+ "@vue/compiler-sfc" "^3.5.13"
+ detective-es6 "^5.0.1"
+ detective-sass "^6.0.1"
+ detective-scss "^5.0.1"
+ detective-stylus "^5.0.1"
+ detective-typescript "^14.0.0"
deterministic-object-hash@^1.3.1:
version "1.3.1"
@@ -16833,6 +16844,9 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
+ dependencies:
+ node-domexception "^1.0.0"
+ web-streams-polyfill "^3.0.3"
fflate@0.8.2, fflate@^0.8.2:
version "0.8.2"
@@ -21307,10 +21321,10 @@ magic-string@^0.26.0, magic-string@^0.26.7:
dependencies:
sourcemap-codec "^1.4.8"
-magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.11, magic-string@^0.30.17, magic-string@^0.30.18, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@^0.30.8, magic-string@~0.30.0:
- version "0.30.18"
- resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.18.tgz#905bfbbc6aa5692703a93db26a9edcaa0007d2bb"
- integrity sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==
+magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.11, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@^0.30.8, magic-string@~0.30.0:
+ version "0.30.19"
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9"
+ integrity sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
@@ -23085,6 +23099,11 @@ node-cron@^3.0.3:
dependencies:
uuid "8.3.2"
+node-domexception@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
+ integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
+
node-fetch-native@^1.4.0, node-fetch-native@^1.6.3, node-fetch-native@^1.6.4, node-fetch-native@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37"
@@ -31208,7 +31227,7 @@ web-namespaces@^2.0.0:
resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692"
integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==
-web-streams-polyfill@^3.1.1:
+web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.1.1:
version "3.3.3"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==