From b6d4bc2f083aa41269967b312c482990df0cbd5d Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 29 Sep 2025 14:55:19 +0300 Subject: [PATCH 1/8] docs: Reword changelog for google gen ai integration (#17805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GoogleGenerativeAI` could be mistaken for a different SDK, and the package name is also incorrect. It’s best to refer to this as the Google Gen AI SDK, as described in the official documentation: https://cloud.google.com/vertex-ai/generative-ai/docs/sdks/overview . --- > [!NOTE] > Updates CHANGELOG wording to use "Google Gen AI", rename `GoogleGenerativeAI` to `GoogleGenAI`, and correct package from `@google/generative-ai` to `@google/genai`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 872d22384fe4de09ab42072a24a68bf43cc941da. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5dd9918a40..f2bcda2892fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,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 @@ -94,9 +94,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))** From c5bbdc6d80b14c34396e7001dcac07fef9565492 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 29 Sep 2025 16:05:10 +0300 Subject: [PATCH 2/8] fix(core): Remove check and always respect ai.telemetry.functionId for Vercel AI gen spans (#17811) This PR fixes a mismatch between ai.telemetry.functionId and gen_ai.function_id. Function ids were ignored unless the span name contained exactly one dot. This caused: - gen_ai.function_id to be missing or inconsistent for valid generation spans. - Mismatch between ai.telemetry.functionId and gen_ai.function_id, making trace exploration and metrics harder to interpret. We now always respect experimental_telemetry.functionId when present, where function id could be set as part of request. --- > [!NOTE] > Always update Vercel AI generate span names and set `gen_ai.function_id` when `experimental_telemetry.functionId` is present, removing the dot-count check. > > - **Core (Vercel AI span processing)**: > - In `packages/core/src/utils/vercel-ai/index.ts` `processGenerateSpan`: > - Remove `name.split('.')` dot-count check; always apply `experimental_telemetry.functionId`. > - When present, append function ID to the operation name and set `gen_ai.function_id`. > - Clarify comments on telemetry function ID usage. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4b9fa48c29a9191c19494edfb6a1b827e0cf1ad2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- packages/core/src/utils/vercel-ai/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 4b317fe653d6..912dcaee3bc4 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -178,9 +178,10 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute span.setAttribute('ai.pipeline.name', nameWthoutAi); span.updateName(nameWthoutAi); - // If a Telemetry name is set and it is a pipeline span, use that as the operation name + // If a telemetry name is set and the span represents a pipeline, use it as the operation name. + // This name can be set at the request level by adding `experimental_telemetry.functionId`. const functionId = attributes[AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE]; - if (functionId && typeof functionId === 'string' && name.split('.').length - 1 === 1) { + if (functionId && typeof functionId === 'string') { span.updateName(`${nameWthoutAi} ${functionId}`); span.setAttribute('gen_ai.function_id', functionId); } From 4a9946c5e0bfefbe81fa013887b0b86b03c5b699 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 29 Sep 2025 17:01:19 +0200 Subject: [PATCH 3/8] ci: Do not run dependabot on e2e test applications (#17813) Not sure if this will also avoid these warnings from security alerts, but it's worth a try. --- .github/dependabot.yml | 2 ++ 1 file changed, 2 insertions(+) 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/' From 7b40a95d2a9862471971e82400ff4c648637ac35 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 29 Sep 2025 17:52:52 +0200 Subject: [PATCH 4/8] feat(node): Split up http integration into composable parts (#17524) This is a first step to de-compose the node/node-core `httpIntegration` into multiple composable parts: * `httpServerIntegration` - handles request isolation, sessions, trace continuation, so core Sentry functionality * `httpServerSpansIntegration` - emits `http.server` spans for incoming requests The `httpIntegration` sets these up under the hood, and for now it is not really recommended for users to use these stand-alone (though it is possible if users opt-out of the `httpIntegration`). The reason is to remain backwards compatible with users using/customizing the `httpIntegration`. We can revisit this in a later major. These new integrations have a much slimmer API surface, and also allows us to avoid having to prefix all the options etc. with what they are about (e.g. `incomingXXX` or `outgoingXXX`). It also means you can actually tree-shake certain features (span creation) out, in theory. Outgoing request handling remains the same for the time being, once we decoupled this from the otel http instrumentation we can do something similar there. The biggest challenge was how to make it possible to de-compose this without having to monkey patch the http server twice. I opted to allow to add callbacks to the `httpServerIntegration` which it will call on any request. So the `httpServerSpansIntegration` can register a callback for itself and plug into this with little overhead. --- packages/astro/src/index.server.ts | 2 + packages/astro/src/server/sdk.ts | 27 +- packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/core/src/client.ts | 23 + .../core/test/lib/utils/promisebuffer.test.ts | 8 +- packages/google-cloud-serverless/src/index.ts | 2 + packages/node-core/src/index.ts | 3 + .../http/SentryHttpInstrumentation.ts | 120 +--- .../http/httpServerIntegration.ts | 436 +++++++++++++ .../http/httpServerSpansIntegration.ts | 407 ++++++++++++ .../integrations/http/incoming-requests.ts | 599 ------------------ .../node-core/src/integrations/http/index.ts | 95 +-- ....test.ts => httpServerIntegration.test.ts} | 4 +- ....ts => httpServerSpansIntegration.test.ts} | 2 +- packages/node-core/tsconfig.json | 2 +- packages/node/src/index.ts | 2 + packages/node/src/integrations/http.ts | 100 +-- packages/remix/src/server/index.ts | 2 + packages/solidstart/src/server/index.ts | 2 + yarn.lock | 95 +-- 21 files changed, 1110 insertions(+), 825 deletions(-) create mode 100644 packages/node-core/src/integrations/http/httpServerIntegration.ts create mode 100644 packages/node-core/src/integrations/http/httpServerSpansIntegration.ts delete mode 100644 packages/node-core/src/integrations/http/incoming-requests.ts rename packages/node-core/test/integrations/{request-session-tracking.test.ts => httpServerIntegration.test.ts} (98%) rename packages/node-core/test/integrations/{http.test.ts => httpServerSpansIntegration.test.ts} (97%) 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/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/test/lib/utils/promisebuffer.test.ts b/packages/core/test/lib/utils/promisebuffer.test.ts index b1316302e6f6..9c944ffd0c39 100644 --- a/packages/core/test/lib/utils/promisebuffer.test.ts +++ b/packages/core/test/lib/utils/promisebuffer.test.ts @@ -149,10 +149,12 @@ describe('PromiseBuffer', () => { 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/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== From e966cdc00fcecafc8aeecefca416d60efc61cfd4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 29 Sep 2025 18:05:26 +0200 Subject: [PATCH 5/8] doc(core): Fix outdated JSDoc in `beforeSendSpan` (#17815) Spotted that we still documented that the root span does not go through `beforeSendSpan`. This is outdated. Since v9, we also pass the root span to the callback. --- packages/core/src/types-hoist/options.ts | 3 --- 1 file changed, 3 deletions(-) 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 Date: Tue, 30 Sep 2025 09:54:35 +0200 Subject: [PATCH 6/8] fix(aws-serverless): Take `http_proxy` into account when choosing `useLayerExtension` default (#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. Fixes: #17804 --- > [!NOTE] > Disable `useLayerExtension` by default when `http_proxy` is set (unless `no_proxy` exempts localhost), add debug warnings, tests, and changelog entry. > > - **aws-serverless**: > - Consider proxy env vars when defaulting `useLayerExtension` in `packages/aws-serverless/src/init.ts`. > - New `shouldDisableLayerExtensionForProxy()` checks `http_proxy` and `no_proxy` (localhost exemptions). > - Update default: enable only if using Lambda layer, no custom tunnel, and no proxy interference. > - Add debug warnings when disabling due to proxy and when tunneling via extension. > - **Tests**: > - Expand `packages/aws-serverless/test/init.test.ts` to cover proxy/no_proxy scenarios, explicit overrides, and env cleanup. > - **Docs/Changelog**: > - Add Important Changes note explaining new default behavior and how to re-enable with `no_proxy` exemptions. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 53e333f1b6dd90a0b678ec05061ce1f508b65d11. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- CHANGELOG.md | 7 + packages/aws-serverless/src/init.ts | 47 +++- packages/aws-serverless/test/init.test.ts | 260 +++++++++++++++++++++- 3 files changed, 312 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9ab44858c1..358078c1fc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **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. + ## 10.16.0 - feat(logs): Add internal `replay_is_buffering` flag ([#17752](https://github.com/getsentry/sentry-javascript/pull/17752)) 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), + }), + ); + }); + }); }); From 264ad0bc866a9079170ba78d9a2aa81b544c45f8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Sep 2025 12:42:53 +0300 Subject: [PATCH 7/8] feat(nuxt): Implement server middleware instrumentation (#17796) This pull request introduces instrumentation for Nuxt middleware, ensuring that all middleware handlers are automatically wrapped with tracing and error reporting functionality. The integration is achieved through build-time transformation. recap: * Adds a new build-time Rollup plugin (`middlewareInstrumentationPlugin`) that automatically wraps all detected middleware handlers with Sentry instrumentation during the Nitro build process. * Implements the `wrapMiddlewareHandler` utility, which wraps middleware handlers to start a Sentry span, capture request data, record exceptions, and flush events in serverless environments. * Updates the Nuxt module setup to inject Sentry middleware imports and instrumentation hooks during initialization, ensuring the new tracing logic is included in server builds. --- > [!NOTE] > Adds build-time wrapping of Nuxt server middleware with Sentry spans and error capture, plus Nuxt 3/4 e2e and unit tests. > > - **SDK/Runtime**: > - Implement `wrapMiddlewareHandlerWithSentry` in `packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts` to start spans, set attributes (op, origin, route, method, headers), handle hook arrays (`onRequest`, `onBeforeResponse`), and capture errors. > - Add middleware build-time transformation via `packages/nuxt/src/vite/middlewareConfig.ts` (server import + Rollup plugin to wrap `defineEventHandler`/`eventHandler`). > - Integrate instrumentation in `packages/nuxt/src/module.ts` by calling `addMiddlewareImports` and `addMiddlewareInstrumentation` during Nitro init when server config is present. > - **Tests**: > - Add e2e apps and Playwright tests for Nuxt 3 and Nuxt 4 (`dev-packages/e2e-tests/test-applications/nuxt-{3,4}`) covering span creation, attributes, parent-child relationships, and error propagation across hooks and arrays. > - Add unit tests for wrapper behavior in `packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9a4a1f373cf9cad90587f5698bb9f24f4ad0e06a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../nuxt-3/server/api/middleware-test.ts | 15 + .../nuxt-3/server/middleware/01.first.ts | 6 + .../nuxt-3/server/middleware/02.second.ts | 7 + .../nuxt-3/server/middleware/03.auth.ts | 12 + .../nuxt-3/server/middleware/04.hooks.ts | 36 ++ .../server/middleware/05.array-hooks.ts | 47 ++ .../nuxt-3/tests/middleware.test.ts | 332 ++++++++++++ .../nuxt-4/server/api/middleware-test.ts | 15 + .../nuxt-4/server/middleware/01.first.ts | 6 + .../nuxt-4/server/middleware/02.second.ts | 7 + .../nuxt-4/server/middleware/03.auth.ts | 12 + .../nuxt-4/server/middleware/04.hooks.ts | 36 ++ .../server/middleware/05.array-hooks.ts | 47 ++ .../nuxt-4/tests/middleware.test.ts | 332 ++++++++++++ packages/nuxt/src/module.ts | 10 + .../runtime/hooks/wrapMiddlewareHandler.ts | 193 +++++++ packages/nuxt/src/vite/middlewareConfig.ts | 97 ++++ .../hooks/wrapMiddlewareHandler.test.ts | 506 ++++++++++++++++++ 18 files changed, 1716 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/middleware-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/01.first.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/02.second.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/03.auth.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/04.hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/middleware/05.array-hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/middleware-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/01.first.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/02.second.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/04.hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/05.array-hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts create mode 100644 packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts create mode 100644 packages/nuxt/src/vite/middlewareConfig.ts create mode 100644 packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts 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/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(); + }); + }); +}); From 7b7ba0de5dfc87552d6153e891f9c0d154aa2faf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 30 Sep 2025 11:12:46 +0200 Subject: [PATCH 8/8] meta(changelog): Update changelog for 10.17.0 --- CHANGELOG.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 358078c1fc7a..3a1100ba7ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,34 @@ - "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. + 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