From 73449e3bbc06aa2d5ebe4e1f22a75db4a1afd750 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 3 Sep 2025 12:17:27 +0200 Subject: [PATCH 01/15] ref(node): Extract `httpServerIntegration` out of http --- .../tests/sampling.test.ts | 4 +- .../tests/transactions.test.ts | 4 +- .../tests/transactions.test.ts | 4 +- .../tests/sampling.test.ts | 4 +- .../tests/transactions.test.ts | 4 +- .../tests/transactions.test.ts | 4 +- packages/astro/src/server/sdk.ts | 27 +- packages/node-core/src/index.ts | 3 + .../http/SentryHttpInstrumentation.ts | 120 +--- .../http/httpServerIntegration.ts | 456 +++++++++++++ .../http/httpServerSpansIntegration.ts | 392 ++++++++++++ .../integrations/http/incoming-requests.ts | 599 ------------------ .../node-core/src/integrations/http/index.ts | 79 ++- ....test.ts => httpServerIntegration.test.ts} | 4 +- ....ts => httpServerSpansIntegration.test.ts} | 2 +- packages/node/src/index.ts | 2 + packages/node/src/integrations/http.ts | 82 ++- 17 files changed, 1029 insertions(+), 761 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/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts index 60e2424552cd..99bcdaa25bf1 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts @@ -16,7 +16,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { data: { 'sentry.source': 'url', 'sentry.op': 'http.server', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.http.otel.http', url: 'http://localhost:3030/task', 'otel.kind': 'SERVER', 'http.response.status_code': 200, @@ -36,7 +36,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', }, - origin: 'manual', + origin: 'auto.http.otel.http', op: 'http.server', status: 'ok', }); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts index 6141261d8954..417b143d4d73 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts @@ -28,7 +28,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -54,7 +54,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', + origin: 'auto.http.otel.http', }); expect(transactionEvent).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts index 1628a9a03ada..d57f6e8c171c 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts @@ -16,7 +16,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -42,7 +42,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', + origin: 'auto.http.otel.http', }); expect(transactionEvent.contexts?.response).toEqual({ diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts index 134f9f22b429..c2b437b68d95 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts @@ -16,7 +16,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { data: { 'sentry.source': 'url', 'sentry.op': 'http.server', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.http.otel.http', url: 'http://localhost:3030/task', 'otel.kind': 'SERVER', 'http.response.status_code': 200, @@ -36,7 +36,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', }, - origin: 'manual', + origin: 'auto.http.otel.http', op: 'http.server', status: 'ok', }); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts index 08c8f80cd9f0..9e9dca37e115 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts @@ -28,7 +28,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -54,7 +54,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', + origin: 'auto.http.otel.http', }); expect(transactionEvent).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts index f3b1b680f2e9..dbfeea1b8b57 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts @@ -16,7 +16,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -42,7 +42,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', + origin: 'auto.http.otel.http', }); expect(transactionEvent.contexts?.response).toEqual({ 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/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..0df22fff7b3b --- /dev/null +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -0,0 +1,456 @@ +/* eslint-disable max-lines */ +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, RequestEventData, Scope } from '@sentry/core'; +import { + debug, + generateSpanId, + getClient, + getCurrentScope, + getIsolationScope, + httpRequestToRequestData, + stripUrlQueryAndFragment, + withIsolationScope, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { NodeClient } from '../../sdk/client'; +import { INSTRUMENTATION_NAME, MAX_BODY_BYTE_LENGTH } from './constants'; + +type ServerEmit = typeof Server.prototype.emit; + +const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented'); + +interface ServerCallbackOptions { + request: IncomingMessage; + response: ServerResponse; + normalizedRequest: RequestEventData; +} + +const clientToCallbackMap = new WeakMap boolean, options: ServerCallbackOptions) => boolean)[]>(); + +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'; +} + +const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => { + const _options = { + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60_000, + maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + ignoreRequestBody: options.ignoreRequestBody, + }; + + return { + name: 'HttpServer', + 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(INSTRUMENTATION_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, () => { + const callbacks = clientToCallbackMap.get(client); + + if (callbacks?.length) { + return wrapInCallbacks( + () => target.apply(thisArg, args), + { request, response, normalizedRequest }, + callbacks.slice(), + ); + } else { + return target.apply(thisArg, args); + } + }); + }); + }, + }); + + wrappedEmitFns.add(newEmit); + server.emit = newEmit; +} + +/** + * Register a client callback that will be called when a request is received. + */ +export function registerServerCallback( + client: Client, + callback: (fn: () => boolean, options: ServerCallbackOptions) => boolean, +): void { + const callbacks = clientToCallbackMap.get(client) || []; + callbacks.push(callback); + clientToCallbackMap.set(client, callbacks); +} + +/** + * 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(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); + } + } +} + +// Wrap a fn in one or multiple callbacks +function wrapInCallbacks( + fn: () => boolean, + options: ServerCallbackOptions, + callbacks: ((fn: () => boolean, options: ServerCallbackOptions) => boolean)[], +): boolean { + const callback = callbacks.shift(); + + if (!callback) { + return fn(); + } + + return callback(() => wrapInCallbacks(fn, options, callbacks), options); +} 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..144c261ea7fa --- /dev/null +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -0,0 +1,392 @@ +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, + RequestEventData, + Span, + SpanAttributes, + SpanStatus, +} from '@sentry/core'; +import { + debug, + getIsolationScope, + getSpanStatusFromHttpCode, + 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 { INSTRUMENTATION_NAME } from './constants'; +import { registerServerCallback } from './httpServerIntegration'; + +// 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 404 status code are ignored. + * 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], [300, 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, 399], + ]; + + const { onSpanCreated } = options; + // eslint-disable-next-line deprecation/deprecation + const { requestHook, responseHook, applyCustomAttributesOnSpan } = options.instrumentation ?? {}; + + return { + name: 'HttpServerSpans', + setup(client: NodeClient) { + // If no tracing, we can just skip everything here + if (typeof __SENTRY_TRACING__ !== 'undefined' && !__SENTRY_TRACING__) { + return; + } + + function startSpan( + fn: () => boolean, + { + normalizedRequest, + request, + response, + }: { request: IncomingMessage; response: ServerResponse; normalizedRequest: RequestEventData }, + ): boolean { + if ( + shouldIgnoreSpansForIncomingRequest(request, { + ignoreStaticAssets, + ignoreIncomingRequests, + }) + ) { + DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Skipping span creation for incoming request', request.url); + return fn(); + } + + 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}`; + + // 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), + }, + }); + + // 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 fn(); + }); + } + + registerServerCallback(client, 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' && + ignoreStatusCodes.some(code => { + if (typeof code === 'number') { + return code === statusCode; + } + + const [min, max] = code; + return statusCode >= min && statusCode <= max; + }) + ) { + return null; + } + } + + return event; + }, + afterAllSetup(client) { + if (DEBUG_BUILD && 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`.', + ); + } + }, + }; +}) 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 as string, 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; +} 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..7625d4d20877 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -2,6 +2,11 @@ import type { IncomingMessage, RequestOptions } from 'node:http'; import { debug, defineIntegration } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; 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 +84,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,38 +127,52 @@ 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); + + const spans = options.spans ?? true; + // In node-core, for now by default we disable incoming requests spans + // we may revisit this in a future release + 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; + // 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); }, }; }); 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/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..057ef78847f7 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -10,6 +10,8 @@ import { addOriginToSpan, generateInstrumentOnce, getRequestUrl, + httpServerIntegration, + httpServerSpansIntegration, NODE_VERSION, SentryHttpInstrumentation, } from '@sentry/node-core'; @@ -75,6 +77,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,49 +202,65 @@ 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 the "regular" OTEL instrumentation that emits spans + // This is Sentry-specific instrumentation for outgoing request breadcrumbs & trace propagation + instrumentSentryHttp(sentryHttpInstrumentationOptions); + + // 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; - } - } + if (enableServerSpans) { + return serverSpans.processEvent(event); } - return event; }, }; From d1c177fdab9c9590c38c64f96c282b7c090286fa Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 8 Sep 2025 09:59:20 +0200 Subject: [PATCH 02/15] disable spans in node-core --- .../node-core/src/integrations/http/index.ts | 4 +- yarn.lock | 74 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 7625d4d20877..c61635946165 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -149,9 +149,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => const server = httpServerIntegration(serverOptions); const serverSpans = httpServerSpansIntegration(serverSpansOptions); - const spans = options.spans ?? true; - // In node-core, for now by default we disable incoming requests spans + // 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; diff --git a/yarn.lock b/yarn.lock index 6e477bf0a40b..496bc5416dde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1590,6 +1590,13 @@ dependencies: "@babel/types" "^7.28.4" +"@babel/parser@^7.28.3": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" + integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== + dependencies: + "@babel/types" "^7.28.4" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.4": version "7.24.4" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz#6125f0158543fb4edf1c22f322f3db67f21cb3e1" @@ -2659,6 +2666,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" +"@babel/types@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" + integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@bcoe/v8-coverage@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" @@ -4842,6 +4857,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" @@ -9382,6 +9402,17 @@ estree-walker "^2.0.2" source-map-js "^1.2.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== + dependencies: + "@babel/parser" "^7.28.3" + "@vue/shared" "3.5.21" + entities "^4.5.0" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + "@vue/compiler-core@3.5.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.9.tgz#d51fbfe6c18479b27fe6b1723344ba0832e4aacb" @@ -9409,6 +9440,14 @@ "@vue/compiler-core" "3.5.21" "@vue/shared" "3.5.21" +"@vue/compiler-dom@3.5.21": + version "3.5.21" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz#26126447fe1e1d16c8cbac45b26e66b3f7175f65" + integrity sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ== + dependencies: + "@vue/compiler-core" "3.5.21" + "@vue/shared" "3.5.21" + "@vue/compiler-dom@3.5.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.9.tgz#6fa2b7e536ae4c416fc2d60b7e9e33b3410eac7a" @@ -9463,6 +9502,21 @@ postcss "^8.5.6" source-map-js "^1.2.1" +"@vue/compiler-sfc@^3.5.13": + version "3.5.21" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz#e48189ef3ffe334c864c2625389ebe3bb4fa41eb" + integrity sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ== + 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" + estree-walker "^2.0.2" + magic-string "^0.30.18" + postcss "^8.5.6" + source-map-js "^1.2.1" + "@vue/compiler-ssr@3.2.45": version "3.2.45" resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.45.tgz#bd20604b6e64ea15344d5b6278c4141191c983b2" @@ -9479,6 +9533,14 @@ "@vue/compiler-dom" "3.5.21" "@vue/shared" "3.5.21" +"@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== + dependencies: + "@vue/compiler-dom" "3.5.21" + "@vue/shared" "3.5.21" + "@vue/compiler-ssr@3.5.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.9.tgz#e30f8e866589392421abcbfc0e0241470f3ca9a6" @@ -9623,6 +9685,11 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.21.tgz#505edb122629d1979f70a2a65ca0bd4050dc2e54" integrity sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw== +"@vue/shared@3.5.21": + 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.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.9.tgz#713257216ea2cbf4e200cb9ae395c34ae2349385" @@ -21314,6 +21381,13 @@ magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.11, magic-string dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" +magic-string@^0.30.18: + version "0.30.18" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.18.tgz#905bfbbc6aa5692703a93db26a9edcaa0007d2bb" + integrity sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + magicast@^0.2.10: version "0.2.11" resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.2.11.tgz#d5d9339ec59e5322cf331460d8e3db2f6585f5d5" From 18f236eb5f701e05ec96b3a98ca0e70a1281611c Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 8 Sep 2025 09:59:26 +0200 Subject: [PATCH 03/15] Revert "fix test origins" This reverts commit 1705573e216dd896980550e2876c3db8a57d14e2. --- .../tests/sampling.test.ts | 4 ++-- .../tests/transactions.test.ts | 4 ++-- .../node-core-express-otel-v1/tests/transactions.test.ts | 4 ++-- .../tests/sampling.test.ts | 4 ++-- .../tests/transactions.test.ts | 4 ++-- .../node-core-express-otel-v2/tests/transactions.test.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts index 99bcdaa25bf1..60e2424552cd 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts @@ -16,7 +16,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { data: { 'sentry.source': 'url', 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'manual', url: 'http://localhost:3030/task', 'otel.kind': 'SERVER', 'http.response.status_code': 200, @@ -36,7 +36,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', }, - origin: 'auto.http.otel.http', + origin: 'manual', op: 'http.server', status: 'ok', }); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts index 417b143d4d73..6141261d8954 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts @@ -28,7 +28,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'manual', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -54,7 +54,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.otel.http', + origin: 'manual', }); expect(transactionEvent).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts index d57f6e8c171c..1628a9a03ada 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts @@ -16,7 +16,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'manual', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -42,7 +42,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.otel.http', + origin: 'manual', }); expect(transactionEvent.contexts?.response).toEqual({ diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts index c2b437b68d95..134f9f22b429 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts @@ -16,7 +16,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { data: { 'sentry.source': 'url', 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'manual', url: 'http://localhost:3030/task', 'otel.kind': 'SERVER', 'http.response.status_code': 200, @@ -36,7 +36,7 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', }, - origin: 'auto.http.otel.http', + origin: 'manual', op: 'http.server', status: 'ok', }); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts index 9e9dca37e115..08c8f80cd9f0 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts @@ -28,7 +28,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'manual', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -54,7 +54,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.otel.http', + origin: 'manual', }); expect(transactionEvent).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts index dbfeea1b8b57..f3b1b680f2e9 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts @@ -16,7 +16,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.source': 'url', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'manual', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, url: 'http://localhost:3030/test-transaction', @@ -42,7 +42,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.otel.http', + origin: 'manual', }); expect(transactionEvent.contexts?.response).toEqual({ From 5d715a1cab9d2824e4a678df5bdbfd8e7905e0ac Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 8 Sep 2025 11:02:05 +0200 Subject: [PATCH 04/15] re-export everywhere --- packages/astro/src/index.server.ts | 2 ++ packages/aws-serverless/src/index.ts | 2 ++ packages/bun/src/index.ts | 2 ++ packages/google-cloud-serverless/src/index.ts | 2 ++ packages/remix/src/server/index.ts | 2 ++ packages/solidstart/src/server/index.ts | 2 ++ 6 files changed, 12 insertions(+) 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/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/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/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, From e503bc72907cdfad32f412a28983274a5ae9c387 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 8 Sep 2025 11:03:16 +0200 Subject: [PATCH 05/15] fix remix?? --- packages/node/src/integrations/http.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 057ef78847f7..632ba09a776c 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -258,10 +258,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => } }, processEvent(event) { - if (enableServerSpans) { - return serverSpans.processEvent(event); - } - 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); }, }; }); From 07baa315e6aa55a95a5fa191420a4502914fbcd0 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 15 Sep 2025 10:30:43 +0200 Subject: [PATCH 06/15] small adjustments & fixes --- .../http/httpServerIntegration.ts | 19 ++++++++++--------- .../http/httpServerSpansIntegration.ts | 10 +++++++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index 0df22fff7b3b..e19a1e5c245f 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -18,11 +18,12 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; -import { INSTRUMENTATION_NAME, MAX_BODY_BYTE_LENGTH } from './constants'; +import { MAX_BODY_BYTE_LENGTH } from './constants'; type ServerEmit = typeof Server.prototype.emit; const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented'); +const INTEGRATION_NAME = 'Http.Server'; interface ServerCallbackOptions { request: IncomingMessage; @@ -94,7 +95,7 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => }; return { - name: 'HttpServer', + name: INTEGRATION_NAME, setupOnce() { const onHttpServerRequestStart = ((_data: unknown) => { const data = _data as { server: Server }; @@ -166,7 +167,7 @@ function instrumentServer( return target.apply(thisArg, args); } - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling incoming request'); + DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling incoming request'); const isolationScope = getIsolationScope().clone(); const request = args[1] as IncomingMessage; @@ -335,7 +336,7 @@ function patchRequestToCaptureBody( let bodyByteLength = 0; const chunks: Buffer[] = []; - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Patching request.on'); + 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. @@ -359,7 +360,7 @@ function patchRequestToCaptureBody( if (event === 'data') { DEBUG_BUILD && - debug.log(INSTRUMENTATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`); + 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) => { @@ -372,12 +373,12 @@ function patchRequestToCaptureBody( bodyByteLength += bufferifiedChunk.byteLength; } else if (DEBUG_BUILD) { debug.log( - INSTRUMENTATION_NAME, + INTEGRATION_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.'); + DEBUG_BUILD && debug.error(INTEGRATION_NAME, 'Encountered error while storing body chunk.'); } return Reflect.apply(target, thisArg, args); @@ -429,13 +430,13 @@ function patchRequestToCaptureBody( } } catch (error) { if (DEBUG_BUILD) { - debug.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); + debug.error(INTEGRATION_NAME, 'Error building captured request body', error); } } }); } catch (error) { if (DEBUG_BUILD) { - debug.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); + 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 index 144c261ea7fa..ca95fb757a73 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -24,6 +24,7 @@ import { debug, getIsolationScope, getSpanStatusFromHttpCode, + httpHeadersToSpanAttributes, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -32,9 +33,10 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; -import { INSTRUMENTATION_NAME } from './constants'; import { registerServerCallback } from './httpServerIntegration'; +const INTEGRATION_NAME = 'Http.ServerSpans'; + // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -101,7 +103,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions const { requestHook, responseHook, applyCustomAttributesOnSpan } = options.instrumentation ?? {}; return { - name: 'HttpServerSpans', + name: INTEGRATION_NAME, setup(client: NodeClient) { // If no tracing, we can just skip everything here if (typeof __SENTRY_TRACING__ !== 'undefined' && !__SENTRY_TRACING__) { @@ -122,7 +124,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions ignoreIncomingRequests, }) ) { - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Skipping span creation for incoming request', request.url); + DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Skipping span creation for incoming request', request.url); return fn(); } @@ -142,6 +144,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions 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, { @@ -163,6 +166,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions 'http.flavor': httpVersion, 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp', ...getRequestContentLengthAttribute(request), + ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, shouldSendDefaultPii), }, }); From d7d6b5ca0388a90871bece94ba0e7b4161809a1f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 18 Sep 2025 09:30:00 +0200 Subject: [PATCH 07/15] adjust changes --- .../http/httpServerSpansIntegration.ts | 40 ++++++++++++------- .../node-core/src/integrations/http/index.ts | 14 ------- packages/node/src/integrations/http.ts | 17 +------- 3 files changed, 26 insertions(+), 45 deletions(-) diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index ca95fb757a73..3c9eac8c367e 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -63,10 +63,10 @@ export interface HttpServerSpansIntegrationOptions { /** * Do not capture spans for incoming HTTP requests with the given status codes. - * By default, spans with 404 status code are ignored. + * 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], [300, 399]]` + * @default `[[401, 404], [301, 303], [305, 399]]` */ ignoreStatusCodes?: (number | [number, number])[]; @@ -95,7 +95,9 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions const ignoreIncomingRequests = options.ignoreIncomingRequests; const ignoreStatusCodes = options.ignoreStatusCodes ?? [ [401, 404], - [300, 399], + // 300 and 304 are possibly valid status codes we do not want to filter + [301, 303], + [305, 399], ]; const { onSpanCreated } = options; @@ -226,18 +228,12 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions // 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' && - ignoreStatusCodes.some(code => { - if (typeof code === 'number') { - return code === statusCode; - } - - const [min, max] = code; - return statusCode >= min && statusCode <= max; - }) - ) { - return null; + if (typeof statusCode === 'number') { + const shouldDrop = shouldFilterStatusCode(statusCode, ignoreStatusCodes); + if (shouldDrop) { + DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode); + return null; + } } } @@ -394,3 +390,17 @@ function getIncomingRequestAttributesOnResponse(request: IncomingMessage, respon 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/index.ts b/packages/node-core/src/integrations/http/index.ts index c61635946165..229ed5a09b00 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -176,17 +176,3 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => }, }; }); - -/** - * 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/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 632ba09a776c..80c2f9c4f627 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -3,7 +3,7 @@ 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, @@ -15,7 +15,6 @@ import { NODE_VERSION, SentryHttpInstrumentation, } from '@sentry/node-core'; -import { DEBUG_BUILD } from '../debug-build'; import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'Http'; @@ -302,17 +301,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; - }); -} From 55f0289a80789ad71b23b4f60db6f9eadd533291 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 18 Sep 2025 09:41:38 +0200 Subject: [PATCH 08/15] use integration and avoid global module scope --- .../http/httpServerIntegration.ts | 30 ++++----- .../http/httpServerSpansIntegration.ts | 61 +++++++++++-------- .../node-core/src/integrations/http/index.ts | 15 ++--- packages/node/src/integrations/http.ts | 16 +++-- 4 files changed, 60 insertions(+), 62 deletions(-) diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index e19a1e5c245f..8fbdafe42a8c 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -31,7 +31,7 @@ interface ServerCallbackOptions { normalizedRequest: RequestEventData; } -const clientToCallbackMap = new WeakMap boolean, options: ServerCallbackOptions) => boolean)[]>(); +type ServerCallback = (fn: () => boolean, options: ServerCallbackOptions) => boolean; const clientToRequestSessionAggregatesMap = new Map< Client, @@ -94,18 +94,22 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => ignoreRequestBody: options.ignoreRequestBody, }; + const serverCallbacks: ServerCallback[] = []; + return { name: INTEGRATION_NAME, setupOnce() { const onHttpServerRequestStart = ((_data: unknown) => { const data = _data as { server: Server }; - instrumentServer(data.server, _options); + instrumentServer(data.server, _options, serverCallbacks); }) satisfies ChannelListener; subscribe('http.server.request.start', onHttpServerRequestStart); }, - + addServerCallback(callback: ServerCallback) { + serverCallbacks.push(callback); + }, afterAllSetup(client) { if (DEBUG_BUILD && client.getIntegrationByName('Http')) { debug.warn( @@ -125,6 +129,7 @@ export const httpServerIntegration = _httpServerIntegration as ( ) => Integration & { name: 'HttpServer'; setupOnce: () => void; + addServerCallback: (callback: ServerCallback) => void; }; /** @@ -144,6 +149,7 @@ function instrumentServer( sessions: boolean; sessionFlushingDelayMS: number; }, + serverCallbacks: ServerCallback[], ): void { // eslint-disable-next-line @typescript-eslint/unbound-method const originalEmit: ServerEmit = server.emit; @@ -215,13 +221,11 @@ function instrumentServer( .setValue(HTTP_SERVER_INSTRUMENTED_KEY, true); return context.with(ctx, () => { - const callbacks = clientToCallbackMap.get(client); - - if (callbacks?.length) { + if (serverCallbacks?.length) { return wrapInCallbacks( () => target.apply(thisArg, args), { request, response, normalizedRequest }, - callbacks.slice(), + serverCallbacks.slice(), ); } else { return target.apply(thisArg, args); @@ -235,18 +239,6 @@ function instrumentServer( server.emit = newEmit; } -/** - * Register a client callback that will be called when a request is received. - */ -export function registerServerCallback( - client: Client, - callback: (fn: () => boolean, options: ServerCallbackOptions) => boolean, -): void { - const callbacks = clientToCallbackMap.get(client) || []; - callbacks.push(callback); - clientToCallbackMap.set(client, callbacks); -} - /** * 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 diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 3c9eac8c367e..6a3b8f44249f 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -33,7 +33,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; -import { registerServerCallback } from './httpServerIntegration'; +import type { httpServerIntegration } from './httpServerIntegration'; const INTEGRATION_NAME = 'Http.ServerSpans'; @@ -104,9 +104,36 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions // eslint-disable-next-line deprecation/deprecation const { requestHook, responseHook, applyCustomAttributesOnSpan } = options.instrumentation ?? {}; + // We track if setup() was called, which indicates that this integration was added directly, not called from httpIntegration + let _isSetupDirectly = false; + return { name: INTEGRATION_NAME, - setup(client: NodeClient) { + setup() { + _isSetupDirectly = true; + }, + 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: NodeClient) { + if (DEBUG_BUILD && client.getIntegrationByName('Http') && _isSetupDirectly) { + debug.warn( + 'It seems that you have manually added `httpServerSpansIntergation` while `httpIntegration` is also present. Make sure to remove `httpIntegration` when adding `httpServerSpansIntegration`.', + ); + } + // If no tracing, we can just skip everything here if (typeof __SENTRY_TRACING__ !== 'undefined' && !__SENTRY_TRACING__) { return; @@ -222,28 +249,12 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions }); } - registerServerCallback(client, 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 && 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`.', - ); + const serverIntegration = client.getIntegrationByName>('HttpServer'); + if (serverIntegration) { + serverIntegration.addServerCallback(startSpan); + } else { + DEBUG_BUILD && + debug.warn('httpServerSpansIntegration requires the httpServerIntegration to be present. Skipping...'); } }, }; @@ -257,7 +268,7 @@ export const httpServerSpansIntegration = _httpServerSpansIntegration as ( options?: HttpServerSpansIntegrationOptions, ) => Integration & { name: 'HttpServerSpans'; - setup: (client: NodeClient) => void; + afterAllSetup: (client: NodeClient) => void; processEvent: (event: Event) => Event | null; }; diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 229ed5a09b00..15d4dc475a6d 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -1,6 +1,5 @@ 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'; @@ -157,18 +156,16 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => return { name: INTEGRATION_NAME, - - setup(client: NodeClient) { - if (enabledServerSpans) { - serverSpans.setup(client); - } - }, - setupOnce() { server.setupOnce(); instrumentSentryHttp(httpInstrumentationOptions); }, + afterAllSetup(client: NodeClient) { + if (enabledServerSpans) { + serverSpans.afterAllSetup(client); + } + }, 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 diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 80c2f9c4f627..f3839638055e 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -226,15 +226,6 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => return { name: INTEGRATION_NAME, - - setup(client: NodeClient) { - const clientOptions = client.getOptions(); - - if (enableServerSpans && hasSpansEnabled(clientOptions)) { - serverSpans.setup(client); - } - }, - setupOnce() { const clientOptions = (getClient()?.getOptions() || {}) satisfies Partial; const useOtelHttpInstrumentation = _shouldUseOtelHttpInstrumentation(options, clientOptions); @@ -256,6 +247,13 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => instrumentOtelHttp(instrumentationConfig); } }, + afterAllSetup(client: NodeClient) { + const clientOptions = client.getOptions(); + + if (enableServerSpans && hasSpansEnabled(clientOptions)) { + serverSpans.afterAllSetup(client); + } + }, 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 From 0b65f6b2a55cd8963237e5ffe2b6cd1be0deb36f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 18 Sep 2025 10:56:02 +0200 Subject: [PATCH 09/15] actually fix it with a hook --- packages/core/src/client.ts | 23 ++ .../http/httpServerIntegration.ts | 64 ++--- .../http/httpServerSpansIntegration.ts | 262 ++++++++---------- .../node-core/src/integrations/http/index.ts | 11 +- packages/node/src/integrations/http.ts | 14 +- 5 files changed, 180 insertions(+), 194 deletions(-) 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/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index 8fbdafe42a8c..074d57e5f156 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -1,12 +1,12 @@ -/* eslint-disable max-lines */ 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, RequestEventData, Scope } from '@sentry/core'; +import type { AggregationCounts, Client, Integration, IntegrationFn, Scope } from '@sentry/core'; import { + addNonEnumerableProperty, debug, generateSpanId, getClient, @@ -22,17 +22,14 @@ import { MAX_BODY_BYTE_LENGTH } from './constants'; type ServerEmit = typeof Server.prototype.emit; +type StartSpanCallback = (next: () => boolean) => boolean; +type RequestWithOptionalStartSpanCallback = IncomingMessage & { + _startSpanCallback?: StartSpanCallback; +}; + const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented'); const INTEGRATION_NAME = 'Http.Server'; -interface ServerCallbackOptions { - request: IncomingMessage; - response: ServerResponse; - normalizedRequest: RequestEventData; -} - -type ServerCallback = (fn: () => boolean, options: ServerCallbackOptions) => boolean; - const clientToRequestSessionAggregatesMap = new Map< Client, { [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } } @@ -86,6 +83,14 @@ export interface HttpServerIntegrationOptions { 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', callback); +} + const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => { const _options = { sessions: options.sessions ?? true, @@ -94,22 +99,17 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => ignoreRequestBody: options.ignoreRequestBody, }; - const serverCallbacks: ServerCallback[] = []; - return { name: INTEGRATION_NAME, setupOnce() { const onHttpServerRequestStart = ((_data: unknown) => { const data = _data as { server: Server }; - instrumentServer(data.server, _options, serverCallbacks); + instrumentServer(data.server, _options); }) satisfies ChannelListener; subscribe('http.server.request.start', onHttpServerRequestStart); }, - addServerCallback(callback: ServerCallback) { - serverCallbacks.push(callback); - }, afterAllSetup(client) { if (DEBUG_BUILD && client.getIntegrationByName('Http')) { debug.warn( @@ -129,7 +129,6 @@ export const httpServerIntegration = _httpServerIntegration as ( ) => Integration & { name: 'HttpServer'; setupOnce: () => void; - addServerCallback: (callback: ServerCallback) => void; }; /** @@ -149,7 +148,6 @@ function instrumentServer( sessions: boolean; sessionFlushingDelayMS: number; }, - serverCallbacks: ServerCallback[], ): void { // eslint-disable-next-line @typescript-eslint/unbound-method const originalEmit: ServerEmit = server.emit; @@ -221,15 +219,14 @@ function instrumentServer( .setValue(HTTP_SERVER_INSTRUMENTED_KEY, true); return context.with(ctx, () => { - if (serverCallbacks?.length) { - return wrapInCallbacks( - () => target.apply(thisArg, args), - { request, response, normalizedRequest }, - serverCallbacks.slice(), - ); - } else { - return target.apply(thisArg, args); + // 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; + if (callback) { + return callback(() => target.apply(thisArg, args)); } + return target.apply(thisArg, args); }); }); }, @@ -432,18 +429,3 @@ function patchRequestToCaptureBody( } } } - -// Wrap a fn in one or multiple callbacks -function wrapInCallbacks( - fn: () => boolean, - options: ServerCallbackOptions, - callbacks: ((fn: () => boolean, options: ServerCallbackOptions) => boolean)[], -): boolean { - const callback = callbacks.shift(); - - if (!callback) { - return fn(); - } - - return callback(() => wrapInCallbacks(fn, options, callbacks), options); -} diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 6a3b8f44249f..644a1a7c3363 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -11,15 +11,7 @@ import { SEMATTRS_NET_HOST_PORT, SEMATTRS_NET_PEER_IP, } from '@opentelemetry/semantic-conventions'; -import type { - Event, - Integration, - IntegrationFn, - RequestEventData, - Span, - SpanAttributes, - SpanStatus, -} from '@sentry/core'; +import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core'; import { debug, getIsolationScope, @@ -33,7 +25,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; -import type { httpServerIntegration } from './httpServerIntegration'; +import { addStartSpanCallback } from './httpServerIntegration'; const INTEGRATION_NAME = 'Http.ServerSpans'; @@ -104,13 +96,124 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions // eslint-disable-next-line deprecation/deprecation const { requestHook, responseHook, applyCustomAttributesOnSpan } = options.instrumentation ?? {}; - // We track if setup() was called, which indicates that this integration was added directly, not called from httpIntegration - let _isSetupDirectly = false; - return { name: INTEGRATION_NAME, - setup() { - _isSetupDirectly = true; + 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 @@ -127,135 +230,12 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions return event; }, - afterAllSetup(client: NodeClient) { - if (DEBUG_BUILD && client.getIntegrationByName('Http') && _isSetupDirectly) { + afterAllSetup(client) { + if (DEBUG_BUILD && 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 no tracing, we can just skip everything here - if (typeof __SENTRY_TRACING__ !== 'undefined' && !__SENTRY_TRACING__) { - return; - } - - function startSpan( - fn: () => boolean, - { - normalizedRequest, - request, - response, - }: { request: IncomingMessage; response: ServerResponse; normalizedRequest: RequestEventData }, - ): boolean { - if ( - shouldIgnoreSpansForIncomingRequest(request, { - ignoreStaticAssets, - ignoreIncomingRequests, - }) - ) { - DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Skipping span creation for incoming request', request.url); - return fn(); - } - - 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 fn(); - }); - } - - const serverIntegration = client.getIntegrationByName>('HttpServer'); - if (serverIntegration) { - serverIntegration.addServerCallback(startSpan); - } else { - DEBUG_BUILD && - debug.warn('httpServerSpansIntegration requires the httpServerIntegration to be present. Skipping...'); - } }, }; }) satisfies IntegrationFn; @@ -268,7 +248,7 @@ export const httpServerSpansIntegration = _httpServerSpansIntegration as ( options?: HttpServerSpansIntegrationOptions, ) => Integration & { name: 'HttpServerSpans'; - afterAllSetup: (client: NodeClient) => void; + setup: (client: NodeClient) => void; processEvent: (event: Event) => Event | null; }; diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 15d4dc475a6d..19859b68f3c0 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -156,16 +156,17 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => return { name: INTEGRATION_NAME, + setup(client: NodeClient) { + if (enabledServerSpans) { + serverSpans.setup(client); + } + }, setupOnce() { server.setupOnce(); instrumentSentryHttp(httpInstrumentationOptions); }, - afterAllSetup(client: NodeClient) { - if (enabledServerSpans) { - serverSpans.afterAllSetup(client); - } - }, + 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 diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index f3839638055e..3160898e0827 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -226,6 +226,13 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => return { name: INTEGRATION_NAME, + setup(client: NodeClient) { + const clientOptions = client.getOptions(); + + if (enableServerSpans && hasSpansEnabled(clientOptions)) { + serverSpans.setup(client); + } + }, setupOnce() { const clientOptions = (getClient()?.getOptions() || {}) satisfies Partial; const useOtelHttpInstrumentation = _shouldUseOtelHttpInstrumentation(options, clientOptions); @@ -247,13 +254,6 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => instrumentOtelHttp(instrumentationConfig); } }, - afterAllSetup(client: NodeClient) { - const clientOptions = client.getOptions(); - - if (enableServerSpans && hasSpansEnabled(clientOptions)) { - serverSpans.afterAllSetup(client); - } - }, 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 From bc6d5c643c73df1bfc4f5c5ab758acee364bbd09 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 18 Sep 2025 11:35:55 +0200 Subject: [PATCH 10/15] error without server integration --- .../integrations/http/httpServerSpansIntegration.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 644a1a7c3363..b9d525d2bb67 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -231,11 +231,21 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions return event; }, afterAllSetup(client) { - if (DEBUG_BUILD && client.getIntegrationByName('Http')) { + 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; From c2e01bd9006f5570962b0e057bb5832af518e5ac Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 19 Sep 2025 14:03:43 +0200 Subject: [PATCH 11/15] use weakref for request callback --- .../src/integrations/http/httpServerIntegration.ts | 6 +++--- packages/node-core/tsconfig.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index 074d57e5f156..4e236c8ae09e 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -24,7 +24,7 @@ type ServerEmit = typeof Server.prototype.emit; type StartSpanCallback = (next: () => boolean) => boolean; type RequestWithOptionalStartSpanCallback = IncomingMessage & { - _startSpanCallback?: StartSpanCallback; + _startSpanCallback?: WeakRef; }; const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented'); @@ -88,7 +88,7 @@ export interface HttpServerIntegrationOptions { * The callback will receive the next function to continue processing the request. */ export function addStartSpanCallback(request: RequestWithOptionalStartSpanCallback, callback: StartSpanCallback): void { - addNonEnumerableProperty(request, '_startSpanCallback', callback); + addNonEnumerableProperty(request, '_startSpanCallback', new WeakRef(callback)); } const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => { @@ -222,7 +222,7 @@ function instrumentServer( // 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; + const callback = (request as RequestWithOptionalStartSpanCallback)._startSpanCallback?.deref(); if (callback) { return callback(() => target.apply(thisArg, args)); } 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" } } From af0733f6f6bd2f2a79a15060f77d03409538ff1b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 29 Sep 2025 12:11:37 +0200 Subject: [PATCH 12/15] fix lint --- .../src/integrations/http/httpServerSpansIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index b9d525d2bb67..c24c0c68d1da 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -344,7 +344,7 @@ function getContentLength(headers: IncomingHttpHeaders): number | null { const contentLengthHeader = headers['content-length']; if (contentLengthHeader === undefined) return null; - const contentLength = parseInt(contentLengthHeader as string, 10); + const contentLength = parseInt(contentLengthHeader, 10); if (isNaN(contentLength)) return null; return contentLength; From 0574b9d76315ca081e1a11505447b9f859acf364 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 29 Sep 2025 12:38:18 +0200 Subject: [PATCH 13/15] fixes --- packages/core/test/lib/utils/promisebuffer.test.ts | 8 +++++--- .../src/integrations/http/httpServerIntegration.ts | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) 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/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index 4e236c8ae09e..f37ddc07a125 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -22,9 +22,14 @@ 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?: WeakRef; + _startSpanCallback?: WeakRefImpl; }; const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented'); From 128bc72c6e90b0713e9d019c2016c64f14e8084a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 29 Sep 2025 15:00:06 +0200 Subject: [PATCH 14/15] fix yarn --- yarn.lock | 88 +++++++++---------------------------------------------- 1 file changed, 14 insertions(+), 74 deletions(-) diff --git a/yarn.lock b/yarn.lock index 496bc5416dde..c047c2e89c62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1590,13 +1590,6 @@ dependencies: "@babel/types" "^7.28.4" -"@babel/parser@^7.28.3": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" - integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== - dependencies: - "@babel/types" "^7.28.4" - "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.4": version "7.24.4" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz#6125f0158543fb4edf1c22f322f3db67f21cb3e1" @@ -2666,14 +2659,6 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.28.4": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" - integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@bcoe/v8-coverage@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" @@ -4857,11 +4842,6 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/sourcemap-codec@^1.5.5": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" - integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== - "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" @@ -9402,17 +9382,6 @@ estree-walker "^2.0.2" source-map-js "^1.2.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== - dependencies: - "@babel/parser" "^7.28.3" - "@vue/shared" "3.5.21" - entities "^4.5.0" - estree-walker "^2.0.2" - source-map-js "^1.2.1" - "@vue/compiler-core@3.5.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.9.tgz#d51fbfe6c18479b27fe6b1723344ba0832e4aacb" @@ -9440,14 +9409,6 @@ "@vue/compiler-core" "3.5.21" "@vue/shared" "3.5.21" -"@vue/compiler-dom@3.5.21": - version "3.5.21" - resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz#26126447fe1e1d16c8cbac45b26e66b3f7175f65" - integrity sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ== - dependencies: - "@vue/compiler-core" "3.5.21" - "@vue/shared" "3.5.21" - "@vue/compiler-dom@3.5.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.9.tgz#6fa2b7e536ae4c416fc2d60b7e9e33b3410eac7a" @@ -9502,21 +9463,6 @@ postcss "^8.5.6" source-map-js "^1.2.1" -"@vue/compiler-sfc@^3.5.13": - version "3.5.21" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz#e48189ef3ffe334c864c2625389ebe3bb4fa41eb" - integrity sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ== - 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" - estree-walker "^2.0.2" - magic-string "^0.30.18" - postcss "^8.5.6" - source-map-js "^1.2.1" - "@vue/compiler-ssr@3.2.45": version "3.2.45" resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.45.tgz#bd20604b6e64ea15344d5b6278c4141191c983b2" @@ -9533,14 +9479,6 @@ "@vue/compiler-dom" "3.5.21" "@vue/shared" "3.5.21" -"@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== - dependencies: - "@vue/compiler-dom" "3.5.21" - "@vue/shared" "3.5.21" - "@vue/compiler-ssr@3.5.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.9.tgz#e30f8e866589392421abcbfc0e0241470f3ca9a6" @@ -9685,11 +9623,6 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.21.tgz#505edb122629d1979f70a2a65ca0bd4050dc2e54" integrity sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw== -"@vue/shared@3.5.21": - 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.9": version "3.5.9" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.9.tgz#713257216ea2cbf4e200cb9ae395c34ae2349385" @@ -14315,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" @@ -14349,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" @@ -16900,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" @@ -21381,13 +21328,6 @@ magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.11, magic-string dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" -magic-string@^0.30.18: - version "0.30.18" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.18.tgz#905bfbbc6aa5692703a93db26a9edcaa0007d2bb" - integrity sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ== - dependencies: - "@jridgewell/sourcemap-codec" "^1.5.5" - magicast@^0.2.10: version "0.2.11" resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.2.11.tgz#d5d9339ec59e5322cf331460d8e3db2f6585f5d5" From be14b96fe3de540372a1bc855770ef3b3ae09d3d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 29 Sep 2025 15:42:16 +0200 Subject: [PATCH 15/15] yarn fixes --- yarn.lock | 81 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/yarn.lock b/yarn.lock index c047c2e89c62..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" @@ -21321,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" @@ -23099,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" @@ -31222,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==