From c2b209dd9e2edeaa43d0d7e5759a49e68312b802 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 15:24:11 -0700 Subject: [PATCH 01/19] ref(cloudflare): Use the shared request handler wrapper Moves Cloudflare's `wrapRequestHandler` onto `wrapServerRequestHandler`, which `Deno.serve` and `Bun.serve` use since the previous PRs. Cloudflare keeps only what is unique to it: client init, the `cf` and cloud resource contexts, the protocol from `cf.httpProtocol`, the `isStreamingResponse` option, `captureErrors`, and the flush through `waitUntil`, which skips the dispose for a 101 response. The helpers that only the old code used (`captureIncomingRequestBody`, `shouldCreateIncomingRequestSpan`, `getIncomingRequestSpanHooks` and `addRequest`) are gone. `wrapRequestHandler` must run on workers without the `nodejs_compat` flag, such as Shopify Oxygen, and every `@sentry/server-utils` entry loads `node:async_hooks`. So the wrapper moves to a new entry, `@sentry/server-utils/request-handler`, with no Node.js imports. Deno and Bun import it from there too. Cloudflare's module graph test allows this one entry, and a new test in server-utils walks the entry's module graph and fails on any Node.js import. The wrapper gets three optional fields: `isStreamingResponse`, `onDone` (called once when the request is done, with the response if there is one), and a `mechanism` that can be left out to not capture errors. For streamed responses, the shared stream monitor replaces Cloudflare's `TransformStream`. Workers with a compatibility date before 2022-11-30 cannot construct a `ReadableStream` without the `streams_enable_constructors` flag, so the wrapper then ends at handler return, as the `TransformStream` fallback did. The monitor now locks the source body only in `start()`, so the original response stays usable in that case. The wrapper builds the new response from the original one, which keeps `encodeBody: 'manual'`. Visible changes for Cloudflare: - Request spans set `client.address` from `CF-Connecting-IP` (or another forwarding header), as on the other runtimes. This follows `dataCollection.userInfo`, which is `true` by default. - Request spans get the response header attributes, and events get the `response` context. - An error from the source of a streamed response is captured (unless `captureErrors` is `false`), and the span gets an error status. - If the user removed `httpServerIntegration`, request bodies follow `dataCollection.httpBodies`. Before, the SDK attached no body. The minified `withSentry` bundle grows by about 2.4 KB (212,385 to 214,853 bytes), under the 211 KiB limit. Most of it is the request session and client address code, which Cloudflare now shares. Fixes #25577 Fixes JS-3975 Co-Authored-By: Claude Opus 5.5 (1M context) --- .size-limit.js | 1 + .../suites/public-api/startSpan/test.ts | 10 + .../subpath-request-handler/test.ts | 13 +- packages/bun/src/integrations/bunserver.ts | 2 +- .../cloudflare/src/integrations/httpServer.ts | 160 +++--------- packages/cloudflare/src/scope-utils.ts | 8 - .../src/wrapRequestHandlerWithInit.ts | 234 +++--------------- packages/cloudflare/test/request.test.ts | 20 +- .../test/requestModuleGraph.test.ts | 7 +- .../deno/src/wrap-deno-request-handler.ts | 2 +- packages/server-utils/package.json | 5 + packages/server-utils/rollup.npm.config.mjs | 1 + packages/server-utils/src/exports.ts | 2 - .../server-utils/src/index.request-handler.ts | 4 + .../server-utils/src/utils/monitorStream.ts | 51 +++- .../src/utils/wrapServerRequestHandler.ts | 104 ++++++-- .../test/requestHandlerEntry.test.ts | 53 ++++ .../test/utils/monitorStream.test.ts | 58 +++++ .../utils/wrapServerRequestHandler.test.ts | 44 ++++ 19 files changed, 399 insertions(+), 380 deletions(-) create mode 100644 packages/server-utils/src/index.request-handler.ts create mode 100644 packages/server-utils/test/requestHandlerEntry.test.ts diff --git a/.size-limit.js b/.size-limit.js index b841667d9d89..075c3fb89a6c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -467,6 +467,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, + limit: '521 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts index 0fa5d410368b..adf52419022f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts @@ -1,5 +1,6 @@ import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core'; import { + CLIENT_ADDRESS, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_SDK_NAME, SENTRY_SDK_VERSION, @@ -205,6 +206,15 @@ it('sends a streamed span envelope with correct spans for a manually started spa type: 'string', value: 'localhost', }, + // From the `CF-Connecting-IP` header that `wrangler dev` sets. + [CLIENT_ADDRESS]: { + type: 'string', + value: '127.0.0.1', + }, + 'http.response.header.content-type': { + type: 'array', + value: ['text/plain;charset=UTF-8'], + }, 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:.+$/), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts index 596ae6c6ef6d..27e35a07270f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/test.ts @@ -1,16 +1,19 @@ -import type { Event } from '@sentry/core'; +import { HTTP_SERVER } from '@sentry/conventions/op'; import { expect, it } from 'vitest'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('does not capture spans emitted through @opentelemetry/api inside wrapRequestHandler from the /request subpath', async ({ signal, }) => { const runner = createRunner(__dirname) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /'); - expect(event.contexts?.trace?.op).toBe('http.server'); - expect(event.spans).toEqual([]); + // Only the request's segment span. The OpenTelemetry span is not captured. + const spans = getSpansFromEnvelope(envelope); + expect(spans).toHaveLength(1); + expect(spans[0]?.is_segment).toBe(true); + expect(spans[0]?.name).toBe('GET /'); + expect(getSpanOp(spans[0]!)).toBe(HTTP_SERVER); }) .start(signal); diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 887f9582466f..cfdca8dca964 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -2,7 +2,7 @@ import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span, Span import { debug, defineIntegration, getClient, withIsolationScope } from '@sentry/core'; import type { AssertTrue, CheckServerRequestOptions, ServerRequestOptions } from '@sentry/core/server'; import { URL_PATH_PARAMETER_KEY_BASE } from '@sentry/conventions/attributes'; -import { wrapServerRequestHandler } from '@sentry/server-utils'; +import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler'; import type { Server, ServeOptions } from 'bun'; const INTEGRATION_NAME = 'BunServer' as const; diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts index 1975f168819e..dbcc4bb839b0 100644 --- a/packages/cloudflare/src/integrations/httpServer.ts +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -1,15 +1,6 @@ -import type { Client, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; -import { - captureBodyFromWinterCGRequest, - defineIntegration, - getIsolationScope, - hasSpansEnabled, - parseStringToURLObject, - safeCallback, -} from '@sentry/core'; -import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; -import { isStaticAssetRequest } from '@sentry/core/server'; -import { DEBUG_BUILD } from '../debug-build'; +import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions, ServerRequestOptions } from '@sentry/core/server'; const INTEGRATION_NAME = 'HttpServer' as const; @@ -100,21 +91,45 @@ type _ServerRequestOptionsCheck = AssertTrue< > >; -interface HttpServerIntegrationInstance extends HttpServerIntegrationOptions { - name: string; - maxRequestBodySize: MaxRequestBodySize | undefined; +type SupportedServerRequestOptions = Omit< + ServerRequestOptions, + 'sessions' | 'sessionFlushingDelayMS' +>; + +// Every supported shared option is a required key, so a new one fails to compile below until the +// integration passes it on. +type HttpServerRequestOptions = { + [K in keyof Required]: SupportedServerRequestOptions[K]; +}; + +interface HttpServerIntegration extends Integration { + requestOptions: HttpServerRequestOptions; +} + +/** + * The incoming request options of the client's `httpServerIntegration`. Without the integration, + * the SDK attaches no request body, so users can turn body capture off by removing it. + */ +export function getHttpServerRequestOptions(client: Client): ServerRequestOptions { + return ( + client.getIntegrationByName(INTEGRATION_NAME)?.requestOptions ?? { + maxRequestBodySize: 'none', + } + ); } -const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => { +const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegration => { return { name: INTEGRATION_NAME, - maxRequestBodySize: options.maxRequestBodySize, - ignoreRequestBody: options.ignoreRequestBody, - ignoreIncomingRequests: options.ignoreIncomingRequests, - ignoreStaticAssets: options.ignoreStaticAssets ?? true, - spans: options.spans, - onSpanCreated: options.onSpanCreated, - onSpanEnd: options.onSpanEnd, + requestOptions: { + maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, + }, }; }) satisfies IntegrationFn; @@ -137,102 +152,3 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): Ht * ``` */ export const httpServerIntegration = defineIntegration(_httpServerIntegration); - -/** - * Capture the request body based on the HttpServer integration config. - * Called internally by `wrapRequestHandler`. - */ -export async function captureIncomingRequestBody(client: Client, request: Request): Promise { - const integration = client.getIntegrationByName(INTEGRATION_NAME); - - if (!integration) { - return; - } - - const configuredBodySize = integration.maxRequestBodySize; - const effectiveBodySize: MaxRequestBodySize = - configuredBodySize ?? - (client.getDataCollectionOptions().httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - - if (effectiveBodySize === 'none') { - return; - } - - // Skip GET and HEAD requests - they don't have bodies - // Also skip OPTIONS, even if they may have a body, they might not give a lot of extra value - if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') { - return; - } - - const ignoreRequestBody = integration.ignoreRequestBody; - if ( - ignoreRequestBody && - // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. - safeCallback( - DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '', - () => ignoreRequestBody(request.url, request), - () => true, - ) - ) { - return; - } - - const isolationScope = getIsolationScope(); - await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); -} - -/** - * Whether to create a span for the incoming request, based on the HttpServer integration config. - * Called internally by `wrapRequestHandler`. - */ -export function shouldCreateIncomingRequestSpan(client: Client | undefined, request: Request): boolean { - if (!client) { - return false; - } - - const integration = client.getIntegrationByName(INTEGRATION_NAME); - if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) { - return false; - } - - const urlObject = parseStringToURLObject(request.url); - const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; - if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) { - return false; - } - - const ignoreIncomingRequests = integration?.ignoreIncomingRequests; - return !( - ignoreIncomingRequests && - safeCallback( - DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '', - () => ignoreIncomingRequests(urlPath, request), - () => false, - ) - ); -} - -/** - * The `onSpanCreated` and `onSpanEnd` hooks of the HttpServer integration, guarded so that a hook - * that throws does not fail the request. Called internally by `wrapRequestHandler`. - */ -export function getIncomingRequestSpanHooks(client: Client | undefined): { - onSpanCreated: (span: Span, request: Request) => void; - onSpanEnd: (span: Span, request: Request, response: Response) => void; -} { - const integration = client?.getIntegrationByName(INTEGRATION_NAME); - return { - onSpanCreated: (span, request) => - safeCallback( - DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '', - () => integration?.onSpanCreated?.(span, request), - () => undefined, - ), - onSpanEnd: (span, request, response) => - safeCallback( - DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', - () => integration?.onSpanEnd?.(span, request, response), - () => undefined, - ), - }; -} diff --git a/packages/cloudflare/src/scope-utils.ts b/packages/cloudflare/src/scope-utils.ts index 540a4589c1a5..22a3e05aebc7 100644 --- a/packages/cloudflare/src/scope-utils.ts +++ b/packages/cloudflare/src/scope-utils.ts @@ -1,7 +1,6 @@ import { CLOUD_PROVIDER } from '@sentry/conventions/attributes'; import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types'; import type { Scope } from '@sentry/core'; -import { winterCGRequestToRequestData } from '@sentry/core'; /** * Set cloud resource context on scope. @@ -20,10 +19,3 @@ export function addCultureContext(scope: Scope, cf: IncomingRequestCfProperties timezone: cf.timezone, }); } - -/** - * Set request data on scope - */ -export function addRequest(scope: Scope, request: Request): void { - scope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(request) }); -} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 9c2126b9add2..954cf8d7bd3f 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -1,39 +1,15 @@ import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types'; -import { - NETWORK_PROTOCOL_NAME, - NETWORK_PROTOCOL_VERSION, - SENTRY_OP, - SENTRY_SEGMENT_NAME_SOURCE, - HTTP_REQUEST_BODY_SIZE, - USER_AGENT_ORIGINAL, -} from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import type { Span } from '@sentry/core'; -import { - captureException, - continueTrace, - getHttpSpanDetailsFromUrlObject, - hasSpanStreamingEnabled, - httpHeadersToSpanAttributes, - HTTP_SPAN_NAME_FALLBACK, - parseStringToURLObject, - safeCallback, - setHttpStatus, - startSpanManual, - winterCGHeadersToDict, -} from '@sentry/core'; -import { classifyResponseStreaming } from '@sentry/core/server'; +import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; +import type { SpanAttributes } from '@sentry/core'; +import { safeCallback } from '@sentry/core'; +import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler'; import { DEBUG_BUILD } from './debug-build'; -import { - captureIncomingRequestBody, - getIncomingRequestSpanHooks, - shouldCreateIncomingRequestSpan, -} from './integrations/httpServer'; +import { getHttpServerRequestOptions } from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; -import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils'; +import { addCloudResourceContext, addCultureContext } from './scope-utils'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; import { withInvocationIsolationScope } from './utils/invocationScope'; @@ -65,7 +41,7 @@ type InitSdk = (options: CloudflareOptions) => CloudflareClient | undefined; * Same as {@link wrapRequestHandler}, but with the SDK initialization injected. * * Wrappers that are only reachable from the main entry point — where `nodejs_compat` is a - * requirement anyway — pass `init` from `sdk.ts` so `enableOpenTelemetrySetup` is honored. + * requirement anyway — pass `init` from `sdk.ts` to get the full default integrations. * * @internal */ @@ -87,7 +63,6 @@ export function wrapRequestHandlerWithInit( : context ? getOriginalWaitUntil(context).bind(context) : undefined; - const errorMechanismType = getRequestErrorMechanismType(context); const client = initSdk({ ...options, ctx: context }); isolationScope.setClient(client); @@ -97,43 +72,8 @@ export function wrapRequestHandlerWithInit( // request, where workerd allows publish, and init() above has registered subscribers. flushDeferredChannelEvents(); - const urlObject = parseStringToURLObject(request.url); - const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( - urlObject, - 'server', - 'auto.http.cloudflare', - request, - undefined, - client, - ); - // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. - // A `route` source means the name already is (e.g. the `/` path), so it is kept as-is. - const name = - attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client) - ? rawName - : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK; - - const contentLength = request.headers.get('content-length'); - if (contentLength) { - attributes[HTTP_REQUEST_BODY_SIZE] = parseInt(contentLength, 10); - } - - const userAgentHeader = request.headers.get('user-agent'); - if (userAgentHeader) { - attributes[USER_AGENT_ORIGINAL] = userAgentHeader; - } - - if (client) { - Object.assign( - attributes, - httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), client.getDataCollectionOptions()), - ); - } - - attributes[SENTRY_OP] = HTTP_SERVER; - addCloudResourceContext(isolationScope); - addRequest(isolationScope, request); + const attributes: SpanAttributes = {}; if (request.cf) { addCultureContext(isolationScope, request.cf); @@ -144,143 +84,47 @@ export function wrapRequestHandlerWithInit( } } - // Do not capture spans for OPTIONS and HEAD requests - if (request.method === 'OPTIONS' || request.method === 'HEAD') { + if (!client) { try { return await handler(); - } catch (e) { - if (captureErrors) { - captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); - } - throw e; } finally { waitUntil?.(flushAndDispose(client)); } } - if (client) { - await captureIncomingRequestBody(client, request); - } - - const { onSpanCreated, onSpanEnd } = getIncomingRequestSpanHooks(client); - - // Runs the handler. Ends the span (if any) and flushes when the response is done. - const respond = async (span: Span | undefined): Promise => { - let res: Response; - - try { - res = await handler(); - if (span) { - setHttpStatus(span, res.status); - } - - // After the handler runs, the span name might have been updated by nested instrumentation - // (e.g., Remix parameterizing routes). The span should already have the correct name - // from that instrumentation, so we don't need to do anything here. - } catch (e) { - span?.end(); - if (captureErrors) { - captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); - } - waitUntil?.(flushAndDispose(client)); - throw e; - } - - let ended = false; - const endSpan = (): void => { - if (ended) return; - - ended = true; - if (span) { - onSpanEnd(span, request, res); - span.end(); - } - }; - - const userClassification = safeCallback( - DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', - () => options.isStreamingResponse?.(res, request), - () => undefined, - ); - // An async callback returns a promise, which would otherwise count as `true`. - const isStreaming = - typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; - - if (isStreaming && res.body) { - try { - let transformerUsed = false; - - const endSpanOnce = (): void => { - if (ended) return; - - endSpan(); + // Note: The span will not have a duration unless I/O happens in the handler. This is + // because of how the cloudflare workers runtime works. + // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ + return wrapServerRequestHandler( + { + request, + client, + isolationScope, + options: getHttpServerRequestOptions(client), + origin: 'auto.http.cloudflare', + mechanism: captureErrors ? { handled: false, type: getRequestErrorMechanismType(context) } : undefined, + attributes, + isStreamingResponse: response => { + const classification = safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(response, request), + () => undefined, + ); + // An async callback returns a promise, which would otherwise count as `true`. + return typeof classification === 'boolean' ? classification : undefined; + }, + onDone: response => { + // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. + // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers + // will still be called and may need the client to capture errors. + if (response?.status === 101) { + waitUntil?.(client.flush(2000)); + } else { waitUntil?.(flushAndDispose(client)); - }; - - // Workers with a compatibility date before 2022-11-30 and without the - // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen - // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a - // used transformer runs `start`. - const transform = new TransformStream({ - start() { - transformerUsed = true; - }, - flush() { - // Source stream completed normally. - endSpanOnce(); - }, - cancel() { - // Client disconnected (or downstream cancelled). The `cancel` - // is being called while the response is still considered - // active, so this is a safe place to end the span. - endSpanOnce(); - }, - }); - - if (transformerUsed) { - // Passing the original response as the init keeps `encodeBody: 'manual'`, which a - // Response does not expose as a property. Without it, workerd compresses a - // pre-compressed body a second time. - return new Response(res.body.pipeThrough(transform), res); } - } catch { - // Falls back to ending the span at handler return. - } - } - - // Non-streaming response - end span immediately and return original - endSpan(); - - // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. - // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers - // will still be called and may need the client to capture errors. - if (res.status === 101) { - waitUntil?.(client?.flush(2000)); - } else { - waitUntil?.(flushAndDispose(client)); - } - return res; - }; - - const createSpan = shouldCreateIncomingRequestSpan(client, request); - - return continueTrace( - { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, - () => { - if (!createSpan) { - return respond(undefined); - } - - // Note: This span will not have a duration unless I/O happens in the handler. This is - // because of how the cloudflare workers runtime works. - // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ - - // Use startSpanManual to control when span ends (needed for streaming responses) - return startSpanManual({ name, attributes }, span => { - onSpanCreated(span, request); - return respond(span); - }); + }, }, + () => handler(), ); }, wrapperOptions.context); } diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 972180c62a8c..d05ad7420d44 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -677,6 +677,7 @@ describe('withSentry', () => { 'http.response.status_code': 200, 'http.request.body.size': 10, 'http.request.header.content-length': ['10'], + 'http.response.header.content-type': ['text/plain;charset=UTF-8'], }, op: 'http.server', origin: 'auto.http.cloudflare', @@ -1297,13 +1298,16 @@ describe('flushAndDispose', () => { }, ); - test('tears down at handler return when TransformStream ignores the transformer', async () => { - // workerd without `transformstream_enable_standard_constructor` returns an identity stream. + test('tears down at handler return when the ReadableStream constructor is not available', async () => { + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); + // workerd before the 2022-11-30 compatibility date, without `streams_enable_constructors`. vi.stubGlobal( - 'TransformStream', - class extends TransformStream { + 'ReadableStream', + class { constructor() { - super(); + throw new TypeError('To use the new ReadableStream() constructor, enable streams_enable_constructors.'); } }, ); @@ -1316,16 +1320,14 @@ describe('flushAndDispose', () => { const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); - const response = new Response('
shell
', { - headers: { 'content-type': 'text/x-component;charset=utf-8' }, - }); - const result = await wrapRequestHandler( { options, request: new Request('https://example.com'), context }, () => response, ); expect(result).toBe(response); + expect(result.bodyUsed).toBe(false); + expect(result.body?.locked).toBe(false); expect(waitUntil).toHaveBeenCalledTimes(1); flushSpy.mockRestore(); diff --git a/packages/cloudflare/test/requestModuleGraph.test.ts b/packages/cloudflare/test/requestModuleGraph.test.ts index 64827ebc8b5c..8b021862a819 100644 --- a/packages/cloudflare/test/requestModuleGraph.test.ts +++ b/packages/cloudflare/test/requestModuleGraph.test.ts @@ -69,7 +69,12 @@ describe('module graph of `wrapRequestHandler`', () => { it('contains no package that depends on Node.js APIs', () => { // `@sentry/server-utils` subscribes to `node:diagnostics_channel` and `@sentry/node` needs Node.js // throughout. Both are only allowed in `sdk.ts`, `index.ts` and `vite/` (see `.oxlintrc.json`). - expect(externals.filter(specifier => /^@sentry\/(node|server-utils)(\/|$)/.test(specifier))).toEqual([]); + // The `request-handler` entry of `@sentry/server-utils` has no Node.js imports. + const nodePackages = externals.filter( + specifier => + /^@sentry\/(node|server-utils)(\/|$)/.test(specifier) && specifier !== '@sentry/server-utils/request-handler', + ); + expect(nodePackages).toEqual([]); }); it('does not reach `async.ts`, the only shipped module importing `node:async_hooks`', () => { diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 4d24405d58e4..e050c5523a2d 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -1,7 +1,7 @@ import type { Client } from '@sentry/core'; import { debug, getClient, withIsolationScope } from '@sentry/core'; import type { ServerRequestOptions } from '@sentry/core/server'; -import { wrapServerRequestHandler } from '@sentry/server-utils'; +import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler'; export type RequestHandlerWrapperOptions = { request: Request; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 2e270b6e9338..62f969f8ddfc 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -27,6 +27,11 @@ "import": "./build/esm/index.no-diagnostic-channels.js", "require": "./build/cjs/index.no-diagnostic-channels.js" }, + "./request-handler": { + "types": "./build/types/index.request-handler.d.ts", + "import": "./build/esm/index.request-handler.js", + "require": "./build/cjs/index.request-handler.js" + }, "./orchestrion/config": { "types": "./build/types/orchestrion/config/index.d.ts", "import": "./build/esm/orchestrion/config/index.js", diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index ab0a51d3ef21..74f172ec5215 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -100,6 +100,7 @@ export default [ entrypoints: [ 'src/index.ts', 'src/index.no-diagnostic-channels.ts', + 'src/index.request-handler.ts', 'src/orchestrion/config/index.ts', 'src/orchestrion/bundler/vite.ts', 'src/orchestrion/bundler/rollup.ts', diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts index 799ed7c69d18..15381f95dc9b 100644 --- a/packages/server-utils/src/exports.ts +++ b/packages/server-utils/src/exports.ts @@ -1,7 +1,5 @@ // Shared exports not using diagnostics channels export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; -export { wrapServerRequestHandler } from './utils/wrapServerRequestHandler'; -export type { ServerRequestHandlerConfig } from './utils/wrapServerRequestHandler'; export { setAsyncLocalStorageAsyncContextStrategy } from './async-context'; export { openTelemetryIntegration, getOtlpTracesEndpoint } from './opentelemetry'; export * from './ai'; diff --git a/packages/server-utils/src/index.request-handler.ts b/packages/server-utils/src/index.request-handler.ts new file mode 100644 index 000000000000..73e0902cc088 --- /dev/null +++ b/packages/server-utils/src/index.request-handler.ts @@ -0,0 +1,4 @@ +// The web `Request` handler wrapper, on its own entry with no Node.js imports, so Cloudflare can use +// it on workers without the `nodejs_compat` compatibility flag. +export { wrapServerRequestHandler } from './utils/wrapServerRequestHandler'; +export type { ServerRequestHandlerConfig } from './utils/wrapServerRequestHandler'; diff --git a/packages/server-utils/src/utils/monitorStream.ts b/packages/server-utils/src/utils/monitorStream.ts index 6b4651f990d1..20a69a15e79a 100644 --- a/packages/server-utils/src/utils/monitorStream.ts +++ b/packages/server-utils/src/utils/monitorStream.ts @@ -1,39 +1,68 @@ -import { isError } from '@sentry/core'; +import { debug, isError } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; -/** Monitor the source without reading ahead or treating cancellation as a source failure. */ +/** + * Monitor the source without reading ahead or treating cancellation as a source failure. + * + * `onDone` runs once, while the returned stream is still open: before it closes, before it errors, + * or when the consumer cancels it. A runtime can end the request when the response body closes + * (Cloudflare Workers then reject `waitUntil`), so work that `onDone` starts must begin first. + */ export function monitorStream( stream: ReadableStream, onDone: () => void, onError: (error: unknown) => void, ): ReadableStream { - const reader = stream.getReader(); - // A cancelled reader closes normally; only a source failure should be captured as an error. - // A source that stops on `request.signal` errors with an `AbortError` when the client - // disconnects, which is a cancellation too. - void reader.closed.then(onDone, error => { - if (!(isError(error) && error.name === 'AbortError')) { - onError(error); + let reader: ReadableStreamDefaultReader; + let finished = false; + const finish = (error?: { error: unknown }): void => { + if (finished) { + return; } - onDone(); - }); + finished = true; + // A callback that throws must not fail the response body. + try { + // A source that stops on `request.signal` errors with an `AbortError` when the client + // disconnects, which is a cancellation too. + if (error && !(isError(error.error) && error.error.name === 'AbortError')) { + onError(error.error); + } + onDone(); + } catch (e) { + DEBUG_BUILD && debug.error('Error when a monitored stream finished:', e); + } + }; return new ReadableStream( { + // Locks the source only once the stream exists, so a constructor that throws leaves it usable. + start() { + reader = stream.getReader(); + // A source can fail while no one reads from it. A cancelled reader closes normally; only a + // source failure should be captured as an error. + void reader.closed.then( + () => finish(), + error => finish({ error }), + ); + }, async pull(controller) { try { const { done, value } = await reader.read(); if (done) { + finish(); controller.close(); reader.releaseLock(); } else { controller.enqueue(value); } } catch (error) { + finish({ error }); controller.error(error); reader.releaseLock(); } }, async cancel(reason) { + finish(); try { await reader.cancel(reason); } finally { diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts index 447764ddf5e5..3f833ba9047e 100644 --- a/packages/server-utils/src/utils/wrapServerRequestHandler.ts +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -8,11 +8,12 @@ import { USER_AGENT_ORIGINAL, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import type { Client, Mechanism, Scope, Span, SpanAttributes } from '@sentry/core'; +import type { Client, Mechanism, Scope, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, continueTrace, + getCurrentScope, getHttpSpanDetailsFromUrlObject, hasSpansEnabled, hasSpanStreamingEnabled, @@ -25,6 +26,8 @@ import { startSpanManual, winterCGHeadersToDict, winterCGRequestToRequestData, + withIsolationScope, + withScope, } from '@sentry/core'; import type { ServerRequestOptions } from '@sentry/core/server'; import { @@ -45,14 +48,21 @@ export interface ServerRequestHandlerConfig { options: ServerRequestOptions | undefined; /** Origin of the span, e.g. `auto.http.deno`. */ origin: string; - /** Mechanism of errors that the handler or the response stream throws. */ - mechanism: Mechanism; + /** Mechanism of errors that the handler or the response stream throws. Omit to not capture them. */ + mechanism?: Mechanism; /** The address and port of the socket, if the runtime knows them. */ getSocketAddress?: () => { address: string; port?: number } | undefined; /** The matched route of the server framework, e.g. `/users/:id`. Sets `http.route` and the span name. */ routeName?: string; /** Span attributes that only the runtime knows, e.g. the route parameters. */ attributes?: SpanAttributes; + /** Whether the response streams. Return `undefined` to use the default classification. */ + isStreamingResponse?: (response: Response) => boolean | undefined; + /** + * Called once when the request is done: when the response is returned, when a streamed body + * finishes, or when the handler throws (then without a response). + */ + onDone?: (response: Response | undefined) => void; } /** @@ -74,6 +84,11 @@ export async function wrapServerRequestHandler { const { request, client, isolationScope, options, mechanism } = config; const method = request.method.toUpperCase(); + const captureError = (error: unknown): void => { + if (mechanism) { + captureException(error, { mechanism }); + } + }; const endSession = options?.sessions ? startRequestSession(client, { @@ -81,6 +96,14 @@ export async function wrapServerRequestHandler { + endSession?.(); + config.onDone?.(response); + }; + const isStreamingResponse = (response: Response): boolean => { + const classification = config.isStreamingResponse?.(response); + return typeof classification === 'boolean' ? classification : classifyResponseStreaming(response).isStreaming; + }; const socketAddress = config.getSocketAddress?.(); isolationScope.setSDKProcessingMetadata({ @@ -89,13 +112,15 @@ export async function wrapServerRequestHandler endSession?.()); + const inRequestScope = bindToRequestScope(isolationScope); + return onResponseDone( + response, + !!response && isStreamingResponse(response), + inRequestScope(() => finish(response)), + inRequestScope(captureError), + ); } const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( @@ -206,8 +237,8 @@ export async function wrapServerRequestHandler { + !!response && isStreamingResponse(response), + inRequestScope(() => { if (onSpanEnd && response) { safeCallback( DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', @@ -231,15 +263,35 @@ export async function wrapServerRequestHandler { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureError(error); + }), ); }); }, ); } +/** + * Returns a function that binds a callback to the request's isolation scope and the current scope. + * + * A streamed body finishes after the handler returns. Without `AsyncLocalStorage` (Cloudflare + * Workers without `nodejs_compat`), its callbacks then run outside the request's scopes, where + * `getClient()` finds no client: the segment span would be sent as a transaction, and a captured + * error would not update the request session. + */ +function bindToRequestScope( + isolationScope: Scope, +): (fn: (...args: A) => void) => (...args: A) => void { + const scope = getCurrentScope(); + return fn => + (...args) => + withIsolationScope(isolationScope, () => withScope(scope, () => fn(...args))); +} + function shouldCreateSpan( client: Client, options: ServerRequestOptions | undefined, @@ -269,17 +321,19 @@ function shouldCreateSpan( */ function onResponseDone( response: R, - mechanism: Mechanism, + isStreaming: boolean, onDone: () => void, - span?: Span, + onStreamError: (error: unknown) => void, ): R { - if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { - const body = monitorStream(response.body, onDone, error => { - span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { mechanism }); - }); - // Passing the original response as the init keeps its status, status text and headers. - return new Response(body, response) as R; + if (isStreaming && response?.body && !response.body.locked) { + try { + // Passing the original response as the init keeps its status, status text and headers, and + // on Cloudflare also `encodeBody: 'manual'`, so workerd does not compress a compressed body again. + return new Response(monitorStream(response.body, onDone, onStreamError), response) as R; + } catch { + // Workers with a compatibility date before 2022-11-30 and without the + // `streams_enable_constructors` flag cannot construct a `ReadableStream`, so end at once. + } } onDone(); return response; diff --git a/packages/server-utils/test/requestHandlerEntry.test.ts b/packages/server-utils/test/requestHandlerEntry.test.ts new file mode 100644 index 000000000000..9df030fa3420 --- /dev/null +++ b/packages/server-utils/test/requestHandlerEntry.test.ts @@ -0,0 +1,53 @@ +import { builtinModules } from 'node:module'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Cloudflare imports the `request-handler` entry on workers without the `nodejs_compat` compatibility + * flag, where any Node.js import fails at startup. Assert on the module graph itself rather than on + * any bundler's output. + */ +const ENTRY_POINT = resolve(__dirname, '../src/index.request-handler.ts'); +const IMPORT_SPECIFIER_REGEX = /\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g; + +function collectExternals(entryPoint: string): string[] { + const modules = new Set(); + const externals = new Set(); + const queue = [entryPoint]; + + while (queue.length) { + const importer = queue.pop() as string; + if (modules.has(importer)) { + continue; + } + modules.add(importer); + + for (const match of readFileSync(importer, 'utf8').matchAll(IMPORT_SPECIFIER_REGEX)) { + const specifier = match[1] ?? (match[2] as string); + if (specifier.startsWith('.')) { + const withoutExtension = join(dirname(importer), specifier); + const resolved = [`${withoutExtension}.ts`, join(withoutExtension, 'index.ts')].find(existsSync); + if (!resolved) { + throw new Error(`Could not resolve '${specifier}' imported from '${importer}'`); + } + queue.push(resolved); + } else { + externals.add(specifier); + } + } + } + + return [...externals].sort(); +} + +describe('module graph of the `request-handler` entry', () => { + it('contains no Node.js built-in module and no package that needs Node.js', () => { + const externals = collectExternals(ENTRY_POINT); + + expect(externals.filter(specifier => specifier.startsWith('node:') || builtinModules.includes(specifier))).toEqual( + [], + ); + expect(externals.filter(specifier => /^@sentry\/(node|server-utils)(\/|$)/.test(specifier))).toEqual([]); + }); +}); diff --git a/packages/server-utils/test/utils/monitorStream.test.ts b/packages/server-utils/test/utils/monitorStream.test.ts index fee33b3cec75..d0921d34833d 100644 --- a/packages/server-utils/test/utils/monitorStream.test.ts +++ b/packages/server-utils/test/utils/monitorStream.test.ts @@ -117,4 +117,62 @@ describe('monitorStream', () => { await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); expect(onError).not.toHaveBeenCalled(); }); + + it('calls onDone before the stream closes', async () => { + const calls: string[] = []; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encode('a')); + controller.close(); + }, + }); + const reader = monitorStream( + source, + () => calls.push('onDone'), + () => calls.push('onError'), + ).getReader(); + void reader.closed.then(() => calls.push('closed')); + + await reader.read(); + await reader.read(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(calls).toEqual(['onDone', 'closed']); + }); + + it('calls onDone in the same task as the cancel', async () => { + const onDone = vi.fn(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encode('a')); + }, + }); + const reader = monitorStream(source, onDone, vi.fn()).getReader(); + await reader.read(); + + const cancelled = reader.cancel('client disconnected'); + expect(onDone).toHaveBeenCalledTimes(1); + + await cancelled; + await new Promise(resolve => setTimeout(resolve, 0)); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('still delivers the body when onDone throws', async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encode('ok')); + controller.close(); + }, + }); + const body = monitorStream( + source, + () => { + throw new Error('waitUntil failed'); + }, + vi.fn(), + ); + + expect(await new Response(body).text()).toBe('ok'); + }); }); diff --git a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts index ff8cf8703fd1..f0bbad17ecea 100644 --- a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts +++ b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts @@ -220,4 +220,48 @@ describe('wrapServerRequestHandler', () => { const setCookie = transactions[0]?.contexts?.trace?.data?.['http.response.header.set-cookie'] as string[]; expect(setCookie.map(cookie => cookie.split('=')[0])).toEqual(['first', 'second']); }); + + it('calls onDone once with the response, or without one when the handler throws', async () => { + const done: (number | undefined)[] = []; + const { serve } = setup({}, { onDone: response => done.push(response?.status) }); + + const response = await serve(new Request('https://example.com/ok'), () => new Response('ok', { status: 201 })); + await response.text(); + await expect( + serve(new Request('https://example.com/throws'), () => { + throw new Error('handler failed'); + }), + ).rejects.toThrow('handler failed'); + await serve(new Request('https://example.com/cors', { method: 'OPTIONS' }), () => new Response(null)); + + expect(done).toEqual([201, undefined, 200]); + }); + + it('does not capture errors without a mechanism', async () => { + const { events, serve } = setup({}, { mechanism: undefined }); + + await expect( + serve(new Request('https://example.com/throws'), () => { + throw new Error('handler failed'); + }), + ).rejects.toThrow('handler failed'); + + expect(events).toHaveLength(0); + }); + + it('calls onDone at once when isStreamingResponse returns false', async () => { + const onDone = vi.fn(); + const { serve } = setup({}, { onDone, isStreamingResponse: () => false }); + const source = new ReadableStream({ + start() { + // never closes + }, + }); + const original = new Response(source, { headers: { 'content-type': 'text/event-stream' } }); + + const response = await serve(new Request('https://example.com/events'), () => original); + + expect(response).toBe(original); + expect(onDone).toHaveBeenCalledTimes(1); + }); }); From 8ecf660680c918ac7ae310f91cf17c03cf33112c Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 23 Oct 2026 10:42:50 -0700 Subject: [PATCH 02/19] fix(cloudflare): Attach request data to the current scope without AsyncLocalStorage Without `AsyncLocalStorage` (for example on Shopify Oxygen, or on Workers without `nodejs_compat`), the SDK uses the stack fallback, and `withInvocationIsolationScope` does not fork. The scope it passes to its callback is a clone that is not the current isolation scope. The shared request handler wrote the request data and body to that clone, so an event captured in the handler, which reads the current scope, had no request body. The `hydrogen-react-router-7` E2E test caught this. The handler now gets the current isolation scope. With `AsyncLocalStorage`, that is the same scope as before. Without it, the request data goes where the body went before the move to the shared handler, and events now also get the request URL and method. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/wrapRequestHandlerWithInit.ts | 9 ++-- .../test/request-stack-strategy.test.ts | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/cloudflare/test/request-stack-strategy.test.ts diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 954cf8d7bd3f..1f3ddde1dd6d 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -1,7 +1,7 @@ import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types'; import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; import type { SpanAttributes } from '@sentry/core'; -import { safeCallback } from '@sentry/core'; +import { getIsolationScope, safeCallback } from '@sentry/core'; import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler'; import { DEBUG_BUILD } from './debug-build'; import { getHttpServerRequestOptions } from './integrations/httpServer'; @@ -41,7 +41,7 @@ type InitSdk = (options: CloudflareOptions) => CloudflareClient | undefined; * Same as {@link wrapRequestHandler}, but with the SDK initialization injected. * * Wrappers that are only reachable from the main entry point — where `nodejs_compat` is a - * requirement anyway — pass `init` from `sdk.ts` to get the full default integrations. + * requirement anyway — pass `init` from `sdk.ts` so `enableOpenTelemetrySetup` is honored. * * @internal */ @@ -99,7 +99,10 @@ export function wrapRequestHandlerWithInit( { request, client, - isolationScope, + // Without `AsyncLocalStorage`, `withInvocationIsolationScope` does not fork, and the scope it + // passes in is not the current one. The request data has to go on the current scope, where + // events captured in the handler read it. + isolationScope: getIsolationScope(), options: getHttpServerRequestOptions(client), origin: 'auto.http.cloudflare', mechanism: captureErrors ? { handled: false, type: getRequestErrorMechanismType(context) } : undefined, diff --git a/packages/cloudflare/test/request-stack-strategy.test.ts b/packages/cloudflare/test/request-stack-strategy.test.ts new file mode 100644 index 000000000000..3e3cda68c479 --- /dev/null +++ b/packages/cloudflare/test/request-stack-strategy.test.ts @@ -0,0 +1,45 @@ +// This file sets no async context strategy, so the SDK uses the stack fallback, as on runtimes +// without `AsyncLocalStorage` (for example Shopify Oxygen, or Workers without `nodejs_compat`). + +import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { expect, test, vi } from 'vitest'; +import { httpServerIntegration, wrapRequestHandler } from '../src/request'; + +test('attaches the request data and body to events captured in the handler without AsyncLocalStorage', async () => { + let event: Event | undefined; + const body = JSON.stringify({ payload: 'x'.repeat(4_000) }); + + await wrapRequestHandler( + { + options: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + cacheClient: false, + integrations: [httpServerIntegration({ maxRequestBodySize: 'small' })], + beforeSend(captured) { + event = captured; + return null; + }, + }, + request: new Request('https://example.com/action', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }), + context: { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as never, + }, + () => { + SentryCore.captureException(new Error('Action Error')); + return new Response('error', { status: 500 }); + }, + ); + + expect(event?.request).toEqual( + expect.objectContaining({ + method: 'POST', + url: 'https://example.com/action', + // `maxRequestBodySize: 'small'` truncates at 1,000 bytes. + data: `${body.slice(0, 997)}...`, + }), + ); +}); From 1c8c79cfed237d3a2064a3a3ee18109960204a41 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 11:27:01 -0400 Subject: [PATCH 03/19] feat: Answer managed tunnel requests in the runtimes With `serveTunnelIntegration`, Node, Bun and Deno servers and Cloudflare Workers now answer tunnel requests before the app sees them, whatever framework runs on top. `intercept: false` opts out, for apps that serve the tunnel from their own code. The tunnel never throws into the runtime: before a request is claimed any failure hands it to the app, after that it ends in a 502. The forward to Sentry is aborted when the browser goes away, and the http server instrumentation skips tunnel requests. --- .../managed-tunnel/instrument.server.ts | 1 - .../suites/tracing/managed-tunnel/test.ts | 5 +- .../suites/deno-serve-managed-tunnel/test.ts | 7 +- .../tracing/managed-tunnel/instrument.mjs | 1 - .../suites/tracing/managed-tunnel/test.ts | 7 +- packages/bun/src/index.ts | 2 +- packages/bun/src/integrations/serveTunnel.ts | 217 ++++++++++++++++++ packages/bun/test/managedTunnel.test.ts | 6 +- .../src/wrapRequestHandlerWithInit.ts | 16 +- .../integrations/http/server-subscription.ts | 4 +- packages/core/src/integrations/serveTunnel.ts | 4 +- packages/core/src/server.ts | 2 + packages/core/src/types/options.ts | 8 + packages/core/src/utils/tunnel.ts | 47 +++- packages/core/src/utils/tunnelForward.ts | 60 ++++- packages/core/src/utils/tunnelServer.ts | 36 +++ packages/deno/src/index.ts | 2 +- packages/deno/src/integrations/serveTunnel.ts | 116 ++++++++++ packages/nextjs/src/index.types.ts | 2 + packages/node/src/index.ts | 2 +- packages/node/src/integrations/serveTunnel.ts | 49 ++++ .../src/utils/wrapServerRequestHandler.ts | 13 ++ packages/sveltekit/src/index.types.ts | 2 +- 23 files changed, 578 insertions(+), 31 deletions(-) create mode 100644 packages/bun/src/integrations/serveTunnel.ts create mode 100644 packages/core/src/utils/tunnelServer.ts create mode 100644 packages/deno/src/integrations/serveTunnel.ts create mode 100644 packages/node/src/integrations/serveTunnel.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/instrument.server.ts index fce513e4d092..9c7d270a4801 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/instrument.server.ts @@ -3,6 +3,5 @@ import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudfl export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1, - // A meta-framework answers the tunnel requests, the Worker only drops their server spans. integrations: [serveTunnelIntegration()], })); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts index 98e9570be20a..c104d68a30cd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -10,7 +10,7 @@ function expectServerSpanAt(path: string) { }; } -it('drops the server span of managed tunnel requests', async ({ signal }) => { +it('answers managed tunnel requests before the app, without a server span', async ({ signal }) => { // Envelopes are expected in order, so a span of the tunnel request would arrive before the last one. const runner = createRunner(__dirname) .expect(expectServerSpanAt('/tunnel-path')) @@ -18,7 +18,8 @@ it('drops the server span of managed tunnel requests', async ({ signal }) => { .start(signal); const tunnelPath = await runner.makeRequest('get', '/tunnel-path'); - expect(await runner.makeRequest('post', tunnelPath!, { data: 'envelope' })).toBe('app'); + // The tunnel rejects the body as an envelope. The app would have answered with a 200. + await runner.makeRequest('post', tunnelPath!, { data: 'envelope', expectError: true }); await runner.makeRequest('get', '/after-tunnel'); await runner.completed(); diff --git a/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts b/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts index 4e67982d7e47..b25896253c77 100644 --- a/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts +++ b/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts @@ -7,14 +7,13 @@ import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index. const DSN = 'https://username@domain/123'; -Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => { +Deno.test('Deno.serve answers managed tunnel requests before the app, without a server span', async () => { resetGlobals(); const sink = spanSink(); init({ dsn: DSN, tracesSampleRate: 1, - // A meta-framework answers the tunnel requests, Deno only drops their server spans. integrations: [serveTunnelIntegration()], transport: sink.transport, }); @@ -33,7 +32,9 @@ Deno.test('Deno.serve drops the server span of managed tunnel requests', async ( 'segment span for /after-tunnel', ); - await (await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' })).text(); + const tunnelResponse = await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' }); + // Answered by the tunnel, which rejects the body as an envelope, not by the app. + assertEquals(await tunnelResponse.text(), 'Invalid envelope'); await (await fetch(`${baseUrl}/after-tunnel`)).text(); await flush(); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs index d06a0abfa862..c5c823df1e08 100644 --- a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs @@ -5,7 +5,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, - // A meta-framework answers the tunnel requests, Node only drops their server spans. integrations: [Sentry.serveTunnelIntegration()], transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts index 1504ba345677..57c6666379b9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -9,7 +9,7 @@ describe('managed tunnel', () => { }); createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { - test('drops the server span of tunnel requests', async () => { + test('answers tunnel requests before the app, without a server span', async () => { const runner = createRunner() .expect({ span: container => { @@ -20,7 +20,10 @@ describe('managed tunnel', () => { }) .start(); - await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!); + // The tunnel rejects the empty body. The app would have answered with a 200. + await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!, { + expectError: true, + }); await runner.makeRequest('get', '/after-tunnel'); await runner.completed(); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 5ff613985c91..bcf340d6ac7c 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -171,7 +171,6 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, - serveTunnelIntegration, getTunnelPath, handleTunnelRequest, handleTunnelRequestIfMatched, @@ -220,6 +219,7 @@ export { getInstrumentedModuleNames, } from '@sentry/node'; export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; +export { serveTunnelIntegration } from './integrations/serveTunnel'; export { captureConsoleIntegration, diff --git a/packages/bun/src/integrations/serveTunnel.ts b/packages/bun/src/integrations/serveTunnel.ts new file mode 100644 index 000000000000..623d9b854eec --- /dev/null +++ b/packages/bun/src/integrations/serveTunnel.ts @@ -0,0 +1,217 @@ +import * as http from 'node:http'; +import * as https from 'node:https'; +import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; +import { _INTERNAL_normalizeTunnelBasePath, debug, defineIntegration, getClient } from '@sentry/core'; +import { + _INTERNAL_getTunnelPaths as getTunnelPaths, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, + serveTunnelIntegration as coreServeTunnelIntegration, +} from '@sentry/core/server'; +import type { Server } from 'bun'; + +type BunServeOptions = Parameters[0] & { routes?: Record }; +type BunHandler = (request: Request, server: Server) => Response | Promise; + +let hasPatchedBunServe = false; +let hasPatchedHttpServer = false; + +const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { + const integration = coreServeTunnelIntegration(options); + + return { + ...integration, + setupOnce() { + if (options.intercept === false) { + return; + } + + patchBunServe(); + patchHttpServer(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. + * + * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your + * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. + * + * `Bun.serve` (including a default-exported server) and `node:http` servers answer the tunnel requests before the app + * sees them. To serve them from your own code instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. + * + * By default only envelopes for the server's own DSN are forwarded. + * + * @example + * ```js + * // Server + * Sentry.init({ + * dsn: '__DSN__', + * integrations: [Sentry.serveTunnelIntegration()], + * }); + * + * // Browser + * Sentry.init({ + * dsn: '__DSN__', + * tunnel: true, + * }); + * ``` + */ +export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); + +/** + * Answers tunnel requests ahead of every `Bun.serve` app, including after `server.reload`. Bun starts a + * default-exported server through the same global, so it's covered too. + */ +function patchBunServe(): void { + if (hasPatchedBunServe) { + return; + } + + try { + const originalServe = Bun.serve; + Bun.serve = function (this: unknown, serveOptions: BunServeOptions) { + const server = originalServe.call(this, withTunnel(serveOptions)) as Server; + try { + const originalReload = server.reload.bind(server); + server.reload = (reloadOptions: BunServeOptions) => originalReload(withTunnel(reloadOptions)); + } catch (error) { + debug.warn('Sentry tunnel: could not wrap server.reload', error); + } + + return server; + }; + hasPatchedBunServe = true; + } catch (error) { + debug.warn('Sentry tunnel: could not patch Bun.serve', error); + } +} + +/** + * Adds a `POST` route per tunnel path and answers tunnel requests ahead of `fetch`. Leaves the options as they were if + * anything goes wrong, so the server still starts. + * + * Routes, not only `fetch`: Bun answers a POST under a `'/*'` HTML import or static `Response` without running any + * JavaScript, so only an exact route reaches the tunnel there. A `POST`-only route leaves every other method to the + * app's routes. + */ +function withTunnel(serveOptions: BunServeOptions): BunServeOptions { + try { + if (!serveOptions || typeof serveOptions !== 'object') { + return serveOptions; + } + + const userFetch = serveOptions.fetch as BunHandler | undefined; + if (typeof userFetch === 'function') { + serveOptions.fetch = function (this: Server, request: Request, server: Server) { + return handleServedTunnelRequest(request) ?? userFetch.call(this, request, server); + } as typeof serveOptions.fetch; + } + + const tunnelPaths = getTunnelRoutePaths(); + if (tunnelPaths.length) { + const routes: Record = { ...serveOptions.routes }; + for (const path of tunnelPaths) { + routes[path] = makeTunnelRoute(routes[path], serveOptions); + } + serveOptions.routes = routes as BunServeOptions['routes']; + } + } catch (error) { + debug.warn('Sentry tunnel: could not add the tunnel to Bun.serve', error); + } + + return serveOptions; +} + +/** + * The full tunnel paths, with and without a trailing slash, since Bun routes match exactly. + */ +function getTunnelRoutePaths(): string[] { + const basePath = _INTERNAL_normalizeTunnelBasePath(getClient()?.getOptions()._managedTunnel?.basePath); + + return getTunnelPaths().flatMap(path => [`${basePath}${path}`, `${basePath}${path}/`]); +} + +/** + * Our `POST` handler, merged with whatever the app has on the same path. An app route that is neither a handler nor + * a handler per method (a static `Response` or an HTML import) is left as it is. + */ +function makeTunnelRoute(existing: unknown, serveOptions: BunServeOptions): unknown { + if (typeof existing === 'function') { + const appRoute = existing as BunHandler; + + return (request: Request, server: Server) => + (request.method === 'POST' && handleServedTunnelRequest(request)) || appRoute(request, server); + } + + if (existing !== undefined && Object.getPrototypeOf(existing) !== Object.prototype) { + return existing; + } + + const methods = (existing ?? {}) as Record; + const appPost = methods.POST; + + return { + ...methods, + POST: (request: Request, server: Server) => + handleServedTunnelRequest(request) ?? toApp(request, server, appPost, serveOptions), + }; +} + +/** + * Where a POST on a tunnel path goes when the tunnel doesn't answer it. Bun can't hand a request back to its router, + * so the app's route on the same path, else `fetch`, else a 404. + */ +function toApp( + request: Request, + server: Server, + appPost: unknown, + serveOptions: BunServeOptions, +): Response | Promise { + if (appPost instanceof Response) { + return appPost.clone(); + } + + if (typeof appPost === 'function') { + return (appPost as BunHandler)(request, server); + } + + if (typeof serveOptions.fetch === 'function') { + return (serveOptions.fetch as BunHandler).call(server, request, server); + } + + return new Response('Not Found', { status: 404 }); +} + +/** + * Bun publishes no `http.server.request.start` channel, so the first request of each `node:http` server hands it to + * the same `emit` wrapper Node uses, then goes through it. + */ +function patchHttpServer(): void { + if (hasPatchedHttpServer) { + return; + } + + hasPatchedHttpServer = true; + const instrumented = new WeakSet(); + // In Bun `https.Server` reuses `http.Server`. + for (const ServerClass of new Set([http.Server, https.Server])) { + try { + // oxlint-disable-next-line typescript/unbound-method -- called with the server as `this` below + const originalEmit = ServerClass.prototype.emit; + ServerClass.prototype.emit = function (this: http.Server, event: string, ...args: unknown[]): boolean { + if (event === 'request' && !instrumented.has(this)) { + instrumented.add(this); + instrumentTunnelServer({ server: this }); + + return this.emit(event, ...args); + } + + return originalEmit.call(this, event, ...args) as boolean; + } as typeof originalEmit; + } catch (error) { + debug.warn('Sentry tunnel: could not patch the node:http server', error); + } + } +} diff --git a/packages/bun/test/managedTunnel.test.ts b/packages/bun/test/managedTunnel.test.ts index 971596340b7b..a2b3b8808119 100644 --- a/packages/bun/test/managedTunnel.test.ts +++ b/packages/bun/test/managedTunnel.test.ts @@ -10,13 +10,12 @@ describe('managed tunnel', () => { SentryCore.getCurrentScope().setClient(undefined); }); - test('Bun.serve drops the server span of tunnel requests', async () => { + test('Bun.serve answers tunnel requests before the app, without a server span', async () => { const transactions: string[] = []; init({ dsn: DSN, tracesSampleRate: 1, traceLifecycle: 'static', - // A meta-framework answers the tunnel requests, Bun only drops their server spans. integrations: [serveTunnelIntegration()], beforeSendTransaction: (event: TransactionEvent) => { transactions.push(event.request?.url ?? ''); @@ -33,7 +32,8 @@ describe('managed tunnel', () => { method: 'POST', body: 'envelope', }); - expect(await tunnelResponse.text()).toBe('app'); + // Answered by the tunnel, which rejects the body as an envelope, not by the app. + expect(await tunnelResponse.text()).toBe('Invalid envelope'); await (await fetch(`${baseUrl}/after-tunnel`)).text(); await SentryCore.flush(); server.stop(); diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 1f3ddde1dd6d..a358404bace2 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -2,6 +2,7 @@ import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/work import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; import type { SpanAttributes } from '@sentry/core'; import { getIsolationScope, safeCallback } from '@sentry/core'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler'; import { DEBUG_BUILD } from './debug-build'; import { getHttpServerRequestOptions } from './integrations/httpServer'; @@ -13,9 +14,13 @@ import { addCloudResourceContext, addCultureContext } from './scope-utils'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; import { withInvocationIsolationScope } from './utils/invocationScope'; -function getRequestErrorMechanismType(context: ExecutionContextCompat | undefined): string { +function isDurableObjectContext(context: ExecutionContextCompat | undefined): boolean { // Durable Object fetch handlers use DO state as context (see instrumentDurableObjectWithSentry) - return context && 'storage' in context ? 'auto.faas.cloudflare.durable_object' : 'auto.http.cloudflare'; + return !!context && 'storage' in context; +} + +function getRequestErrorMechanismType(context: ExecutionContextCompat | undefined): string { + return isDurableObjectContext(context) ? 'auto.faas.cloudflare.durable_object' : 'auto.http.cloudflare'; } export interface RequestHandlerWrapperOptions { @@ -72,6 +77,13 @@ export function wrapRequestHandlerWithInit( // request, where workerd allows publish, and init() above has registered subscribers. flushDeferredChannelEvents(); + // The tunnel answers these before the app, with no span. A Durable Object is only reached through the Worker, + // which answered them already. + const tunnelResponse = !isDurableObjectContext(context) && handleServedTunnelRequest(request); + if (tunnelResponse) { + return tunnelResponse.finally(() => waitUntil?.(flushAndDispose(client))); + } + addCloudResourceContext(isolationScope); const attributes: SpanAttributes = {}; if (request.cf) { diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index d75bd2b10703..e0af4f4af3ef 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -24,6 +24,7 @@ import type { HttpIncomingMessage, HttpInstrumentationOptions, HttpServer, HttpS import { DEBUG_BUILD } from '../../debug-build'; import { debug } from '../../utils/debug-logger'; import { safeCallback } from '../../utils/safeCallback'; +import { isServedTunnelRequest } from '../../utils/tunnel'; import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from '../../currentScopes'; import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; import { @@ -126,7 +127,8 @@ export function instrumentServer(options: HttpInstrumentationOptions, server: Ht const client = getClient(); const [request, response] = data as [HttpIncomingMessage, HttpServerResponse]; - if (!client || !markRequest(request)) { + // The tunnel answers these before the app, so they get no isolation scope, session or span. + if (!client || isServedTunnelRequest(request) || !markRequest(request)) { return target.apply(thisArg, args); } diff --git a/packages/core/src/integrations/serveTunnel.ts b/packages/core/src/integrations/serveTunnel.ts index 3994b35a252d..3325501da19d 100644 --- a/packages/core/src/integrations/serveTunnel.ts +++ b/packages/core/src/integrations/serveTunnel.ts @@ -21,8 +21,8 @@ const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. * - * Meta-framework SDKs (e.g. Next.js, Nuxt or SvelteKit) answer the tunnel requests for you. On a standalone server - * (e.g. Express or Fastify), also call `handleTunnelRequestIfMatched` in your request handling. + * The server SDKs answer the tunnel requests before the app sees them. To serve them from your own code instead, set + * `intercept: false` and call `handleTunnelRequestIfMatched`. * * By default only envelopes for the server's own DSN are forwarded. * diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 08c2b3e99a55..0e7b569059eb 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -15,8 +15,10 @@ export { } from './utils/tunnel'; export { forwardTunnelRequest as _INTERNAL_forwardTunnelRequest, + handleServedNodeTunnelRequest as _INTERNAL_handleServedNodeTunnelRequest, handleTunnelRequestIfMatched, } from './utils/tunnelForward'; +export { instrumentTunnelServer as _INTERNAL_instrumentTunnelServer } from './utils/tunnelServer'; export { serveTunnelIntegration } from './integrations/serveTunnel'; export { _INTERNAL_resetManagedTunnelDefaults, diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 443a9f1e59d5..332bd96ad26e 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -671,6 +671,14 @@ export interface BrowserTunnelOptions extends TunnelOptions { export interface ServerTunnelOptions extends TunnelOptions { /** DSNs to forward besides the server's own, e.g. a different browser DSN. */ allowedDsns?: string[]; + + /** + * Whether the SDK answers tunnel requests itself, before they reach the app. Set it to `false` to serve the tunnel + * from your own code instead, e.g. behind your own middleware, with `handleTunnelRequestIfMatched`. + * + * @default true + */ + intercept?: boolean; } /** diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 62144a180134..1321c672ad3b 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -4,7 +4,9 @@ import { getClient } from '../currentScopes'; import { URL_PATH } from '@sentry/conventions/attributes'; import type { DsnComponents, DsnLike } from '../types/dsn'; import type { IgnoreSpanFilter, ServerTunnelOptions, TunnelInit, TunnelOptions } from '../types/options'; +import { DEBUG_BUILD } from '../debug-build'; import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; +import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseUrl } from './url'; @@ -18,6 +20,8 @@ export type TunnelForwarder = (request: Request, allowedDsns: Array) => */ interface ServedTunnel extends TunnelMatch { forward: TunnelForwarder; + /** Whether the SDK answers tunnel requests itself, see `ServerTunnelOptions.intercept`. */ + intercept: boolean; } const servedTunnels = new WeakMap(); @@ -42,6 +46,7 @@ export function setServedTunnel( basePath: tunnelOptions.basePath, allowedDsns, forward, + intercept: tunnelOptions.intercept !== false, }; servedTunnels.set(client, servedTunnel); @@ -223,22 +228,54 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: ServerTunn * there's no fallback, so the framework SDKs serve nothing without the integration. */ export function isServedTunnelRequest(request: TunnelRequestLike): boolean { + try { + return !!getServedTunnelMatch(request); + } catch (error) { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to match a request', error); + + return false; + } +} + +/** + * The served tunnel, if the SDK answers this request on it. + */ +export function getServedTunnelMatch(request: TunnelRequestLike): ServedTunnel | undefined { const servedTunnel = getServedTunnel(); - return !!servedTunnel && matchesTunnel(request, servedTunnel); + return servedTunnel?.intercept && servedTunnel.allowedDsns.length && matchesTunnel(request, servedTunnel) + ? servedTunnel + : undefined; } /** * Forwards the request if it targets the tunnel `serveTunnelIntegration` serves, otherwise returns `undefined`. The - * framework SDKs answer tunnel requests with it. + * runtime and framework SDKs answer tunnel requests with it, ahead of the app. + * + * It never throws, since a throw here would break the app's own requests. Until a request is claimed, a failure hands + * it to the app. Once claimed, its body may be read already, so a failure ends in a 502 instead. */ export function handleServedTunnelRequest(request: Request): Promise | undefined { - const servedTunnel = getServedTunnel(); - if (!servedTunnel?.allowedDsns.length || !matchesTunnel(request, servedTunnel)) { + let handled: Promise; + try { + // An envelope always has a body. Nuxt's Cloudflare plugin passes a copy without one, which its app answers later. + const servedTunnel = request.body && getServedTunnelMatch(request); + if (!servedTunnel) { + return undefined; + } + + handled = servedTunnel.forward(request, servedTunnel.allowedDsns); + } catch (error) { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to match a request', error); + return undefined; } - return servedTunnel.forward(request, servedTunnel.allowedDsns); + return handled.catch(error => { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to forward a request', error); + + return new Response(null, { status: 502 }); + }); } /** diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 01f2af0947bd..7caf7d43cf11 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -4,7 +4,8 @@ import { suppressTracing } from '../tracing/trace'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import type { ServerTunnelOptions } from '../types/options'; -import { matchesTunnel, resolveAllowedDsns, resolveTunnelMatch } from './tunnel'; +import { DEBUG_BUILD } from '../debug-build'; +import { getServedTunnelMatch, matchesTunnel, resolveAllowedDsns, resolveTunnelMatch } from './tunnel'; /** * Options for {@link handleTunnelRequest}. @@ -43,6 +44,9 @@ interface NodeTunnelRequest extends AsyncIterable { interface NodeTunnelResponse { writeHead(statusCode: number, headers?: Record): unknown; end(chunk: Uint8Array): unknown; + headersSent?: boolean; + /** Lets the forward stop when the browser goes away. */ + once?(event: 'close', listener: () => void): unknown; } /** @@ -76,7 +80,7 @@ export function handleTunnelRequest( const nodeResponse = responseOrOptions; return forwardTunnelRequest( - toFetchRequest(requestOrOptions as NodeTunnelRequest), + toFetchRequest(requestOrOptions as NodeTunnelRequest, nodeResponse), getAllowedDsns(nodeOptions), ).then(fetchResponse => writeNodeResponse(nodeResponse, fetchResponse)); } @@ -122,11 +126,50 @@ export function handleTunnelRequestIfMatched( return forwardTunnelRequest(request as Request, tunnel.allowedDsns); } - return forwardTunnelRequest(toFetchRequest(request as NodeTunnelRequest), tunnel.allowedDsns).then(fetchResponse => - writeNodeResponse(response, fetchResponse), + return forwardTunnelRequest(toFetchRequest(request as NodeTunnelRequest, response), tunnel.allowedDsns).then( + fetchResponse => writeNodeResponse(response, fetchResponse), ); } +/** + * Answers a request to the tunnel `serveTunnelIntegration` serves on a Node server, ahead of the app. Returns `true` + * when it claimed the request and answers it, `false` to hand the request to the app. + * + * It never throws, since it runs inside the server's `emit`, where a throw crashes the process. Until a request is + * claimed, a failure hands it to the app. Once claimed, its body may be read already, so a failure ends in a 502. + */ +export function handleServedNodeTunnelRequest(request: NodeTunnelRequest, response: NodeTunnelResponse): boolean { + let handled: Promise; + try { + const servedTunnel = getServedTunnelMatch(request); + if (!servedTunnel) { + return false; + } + + handled = servedTunnel.forward(toFetchRequest(request, response), servedTunnel.allowedDsns); + } catch (error) { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to match a request', error); + + return false; + } + + handled + .then(fetchResponse => writeNodeResponse(response, fetchResponse)) + .catch(error => { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to forward a request', error); + try { + if (!response.headersSent) { + response.writeHead(502); + } + response.end(new Uint8Array()); + } catch { + // The connection is gone. + } + }); + + return true; +} + /** * `allowedDsns` plus the server's own DSN. Without `allowedDsns`, the DSNs `serveTunnelIntegration` allows, so its * options apply here too. @@ -147,7 +190,7 @@ function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; } -function toFetchRequest(request: NodeTunnelRequest): Request { +function toFetchRequest(request: NodeTunnelRequest, response: NodeTunnelResponse): Request { const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { // HTTP/2 pseudo-headers like `:authority` aren't valid fetch header names. @@ -176,6 +219,10 @@ function toFetchRequest(request: NodeTunnelRequest): Request { }, }); + // So the forward stops when the browser goes away, instead of waiting on Sentry for nobody. + const abortController = new AbortController(); + response.once?.('close', () => abortController.abort()); + // The forward reads only the headers and the body. A URL built from the request's `Host` would throw on one // Node accepts, like `a b`. return new Request('http://localhost/', { @@ -183,6 +230,7 @@ function toFetchRequest(request: NodeTunnelRequest): Request { headers, body, duplex: 'half', + signal: abortController.signal, } as RequestInit); } @@ -254,6 +302,8 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr headers: forwardHeaders(request), body: prependToStream(head.read, reader), duplex: 'half', + // Aborts when the browser goes away, so a slow or hanging Sentry doesn't keep the connection open. + signal: request.signal, } as RequestInit), ); } catch (error) { diff --git a/packages/core/src/utils/tunnelServer.ts b/packages/core/src/utils/tunnelServer.ts new file mode 100644 index 000000000000..305bdfb3b9dc --- /dev/null +++ b/packages/core/src/utils/tunnelServer.ts @@ -0,0 +1,36 @@ +import { DEBUG_BUILD } from '../debug-build'; +import type { HttpServer } from '../integrations/http/types'; +import { debug } from './debug-logger'; +import { handleServedNodeTunnelRequest } from './tunnelForward'; + +const tunnelServers = new WeakSet(); + +/** + * Wraps a Node server's `emit` once, so requests to the tunnel `serveTunnelIntegration` serves are answered before + * any of the app's `request` listeners run. A listener for the `http.server.request.start` diagnostics channel, which + * Node publishes right before it emits each request, so even the first request is answered. + * + * It never throws, since Node rethrows a channel listener's error as an uncaught exception. + */ +export function instrumentTunnelServer(message: unknown): void { + try { + const server = (message as { server?: HttpServer } | undefined)?.server; + if (!server || tunnelServers.has(server)) { + return; + } + + tunnelServers.add(server); + // oxlint-disable-next-line typescript/unbound-method -- called with the server as `this` below + const originalEmit = server.emit; + server.emit = function (this: HttpServer, event: string, ...data: unknown[]) { + const [request, response] = data as Parameters; + if (event === 'request' && handleServedNodeTunnelRequest(request, response)) { + return true; + } + + return originalEmit.call(this, event, ...data); + } as HttpServer['emit']; + } catch (error) { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to instrument a server', error); + } +} diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index d26507f6988a..30079ef911b8 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -114,11 +114,11 @@ export { export { handleTunnelRequestIfMatched, isTunnelRequest, - serveTunnelIntegration, trpcMiddleware, wrapMcpServerWithSentry, } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; +export { serveTunnelIntegration } from './integrations/serveTunnel'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { diff --git a/packages/deno/src/integrations/serveTunnel.ts b/packages/deno/src/integrations/serveTunnel.ts new file mode 100644 index 000000000000..dd618921af09 --- /dev/null +++ b/packages/deno/src/integrations/serveTunnel.ts @@ -0,0 +1,116 @@ +import { subscribe } from 'node:diagnostics_channel'; +import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; +import { debug, defineIntegration } from '@sentry/core'; +import { + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, + HTTP_ON_SERVER_REQUEST, + serveTunnelIntegration as coreServeTunnelIntegration, +} from '@sentry/core/server'; + +type ServeHandler = (request: Request, info: Deno.ServeHandlerInfo) => Response | Promise; + +let hasPatchedDenoServe = false; + +const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { + const integration = coreServeTunnelIntegration(options); + + return { + ...integration, + setupOnce() { + if (options.intercept === false) { + return; + } + + // `node:http` servers publish the same channel on Deno as on Node. + subscribe(HTTP_ON_SERVER_REQUEST, instrumentTunnelServer); + patchDenoServe(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. + * + * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your + * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. + * + * `Deno.serve` (including `deno serve`) and `node:http` servers answer the tunnel requests before the app sees them. + * To serve them from your own code instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. + * + * By default only envelopes for the server's own DSN are forwarded. + * + * @example + * ```js + * // Server + * Sentry.init({ + * dsn: '__DSN__', + * integrations: [Sentry.serveTunnelIntegration()], + * }); + * + * // Browser + * Sentry.init({ + * dsn: '__DSN__', + * tunnel: true, + * }); + * ``` + */ +export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); + +/** + * Answers tunnel requests ahead of every `Deno.serve` handler. `deno serve` starts the default export through the + * same global, so it's covered too. + */ +function patchDenoServe(): void { + if (hasPatchedDenoServe) { + return; + } + + try { + const originalServe = Deno.serve; + const descriptor = Object.getOwnPropertyDescriptor(Deno, 'serve'); + // `Deno.serve` is a getter, so it can't be assigned. + Object.defineProperty(Deno, 'serve', { + configurable: descriptor?.configurable ?? true, + enumerable: descriptor?.enumerable ?? true, + writable: true, + value: function (this: unknown, ...args: unknown[]) { + return originalServe.apply(this, withTunnelHandler(args)); + }, + }); + hasPatchedDenoServe = true; + } catch (error) { + debug.warn('Sentry tunnel: could not patch Deno.serve', error); + } +} + +/** + * Wraps the handler of `Deno.serve(handler)`, `Deno.serve(options, handler)` or `Deno.serve({ handler })`. Returns + * the arguments as they were if anything goes wrong, so the server still starts. + */ +function withTunnelHandler(args: unknown[]): unknown[] { + try { + const handlerIndex = args.findIndex(arg => typeof arg === 'function'); + if (handlerIndex !== -1) { + const wrapped = [...args]; + wrapped[handlerIndex] = wrapHandler(args[handlerIndex] as ServeHandler); + + return wrapped; + } + + const [options, ...rest] = args as [{ handler?: unknown } | undefined, ...unknown[]]; + if (options && typeof options.handler === 'function') { + return [{ ...options, handler: wrapHandler(options.handler as ServeHandler) }, ...rest]; + } + } catch (error) { + debug.warn('Sentry tunnel: could not wrap the Deno.serve handler', error); + } + + return args; +} + +function wrapHandler(handler: ServeHandler): ServeHandler { + return function (this: unknown, request, info) { + return handleServedTunnelRequest(request) ?? handler.call(this, request, info); + }; +} diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index baaed91053d2..659d0c23cfc8 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -25,6 +25,8 @@ export declare const contextLinesIntegration: typeof clientSdk.contextLinesInteg export declare const consoleIntegration: typeof serverSdk.consoleIntegration; // Node-only at runtime; the edge build exports an inert shim so named imports resolve in edge-compiled modules. export declare const pinoIntegration: typeof serverSdk.pinoIntegration; +// The Node one also answers tunnel requests on the `node:http` server; the edge one comes from core. +export declare const serveTunnelIntegration: typeof serverSdk.serveTunnelIntegration; // The client uses the `@sentry/core` span-start APIs while server and edge use the Cache // Components-aware ones from `common/utils/nextSpan`. Same signatures, but the star exports above // are ambiguous without these. diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 183069ccbadf..be7b23266a66 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -179,11 +179,11 @@ export { export { handleTunnelRequestIfMatched, isTunnelRequest, - serveTunnelIntegration, trpcMiddleware, wrapMcpServerWithSentry, } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; +export { serveTunnelIntegration } from './integrations/serveTunnel'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; diff --git a/packages/node/src/integrations/serveTunnel.ts b/packages/node/src/integrations/serveTunnel.ts new file mode 100644 index 000000000000..b72559adacbf --- /dev/null +++ b/packages/node/src/integrations/serveTunnel.ts @@ -0,0 +1,49 @@ +import { defineIntegration } from '@sentry/core'; +import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; +import { + _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, + HTTP_ON_SERVER_REQUEST, + serveTunnelIntegration as coreServeTunnelIntegration, +} from '@sentry/core/server'; +import { subscribeDiagnosticsChannel } from '@sentry/server-utils'; + +const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { + const integration = coreServeTunnelIntegration(options); + + return { + ...integration, + setupOnce() { + if (options.intercept !== false) { + subscribeDiagnosticsChannel(HTTP_ON_SERVER_REQUEST, instrumentTunnelServer); + } + }, + }; +}) satisfies IntegrationFn; + +/** + * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. + * + * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your + * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. + * + * Every `node:http` server answers the tunnel requests before the app sees them, whatever framework runs on it. To + * serve them from your own code instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. + * + * By default only envelopes for the server's own DSN are forwarded. + * + * @example + * ```js + * // Server + * Sentry.init({ + * dsn: '__DSN__', + * integrations: [Sentry.serveTunnelIntegration()], + * }); + * + * // Browser + * Sentry.init({ + * dsn: '__DSN__', + * tunnel: true, + * }); + * ``` + */ +export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts index 3f833ba9047e..160948b7ed46 100644 --- a/packages/server-utils/src/utils/wrapServerRequestHandler.ts +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -31,6 +31,7 @@ import { } from '@sentry/core'; import type { ServerRequestOptions } from '@sentry/core/server'; import { + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, classifyResponseStreaming, getClientIPAddress, isStaticAssetRequest, @@ -83,6 +84,18 @@ export async function wrapServerRequestHandler R | Promise, ): Promise { const { request, client, isolationScope, options, mechanism } = config; + + // The tunnel answers these before the app, so they get no session, request data, body or span. + if (isServedTunnelRequest(request)) { + let response: R | undefined; + try { + response = await handler(); + return response; + } finally { + config.onDone?.(response); + } + } + const method = request.method.toUpperCase(); const captureError = (error: unknown): void => { if (mechanism) { diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 5ee8c6faca30..92bf43f90902 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -15,7 +15,7 @@ export * from './server'; export * from './worker'; // Use the ./server version of some functions that are also exported from ./worker -export { sentryHandle } from './server'; +export { sentryHandle, serveTunnelIntegration } from './server'; // Use the ./worker version of some functions that are also exported from ./server export { initCloudflareSentryHandle } from './worker'; From aaa30d07c428e2ffb0c7f9ba4fb23c871c7c33f7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 12:35:56 -0400 Subject: [PATCH 04/19] ref: Remove the framework tunnel hooks The runtimes answer managed tunnel requests now, so the framework SDKs stop answering them in their own request hooks. They keep passing their base path, and TanStack Start's `tunnelRoute` still enables the tunnel. --- packages/astro/src/server/middleware.ts | 10 +- packages/astro/test/server/middleware.test.ts | 22 ---- packages/nitro/src/runtime/plugins/server.ts | 14 +-- .../runtime/plugins/tunnel-legacy.server.ts | 29 +---- .../nuxt/src/runtime/plugins/tunnel.server.ts | 19 +--- packages/nuxt/src/vite/nitroServer.ts | 2 +- .../react-router/src/server/tunnelRoute.ts | 67 +---------- .../src/vite/makeTunnelRoutePlugin.ts | 4 +- .../test/server/tunnelRoute.test.ts | 90 --------------- packages/remix/src/v3/server/instrument.ts | 19 +--- packages/remix/src/v3/server/middleware.ts | 6 - packages/remix/test/v3/instrument.test.ts | 30 ----- packages/remix/test/v3/middleware.test.ts | 13 --- packages/solidstart/src/nitro/tunnelPlugin.ts | 32 +----- .../test/nitro/tunnelPlugin.test.ts | 90 --------------- .../sveltekit/src/server-common/handle.ts | 11 +- .../test/server-common/handle.test.ts | 41 ------- .../src/server/tunnelRoute.ts | 37 +----- .../wrapServerEntryWithSentryTunnel.test.ts | 107 ------------------ 19 files changed, 35 insertions(+), 608 deletions(-) delete mode 100644 packages/react-router/test/server/tunnelRoute.test.ts delete mode 100644 packages/solidstart/test/nitro/tunnelPlugin.test.ts diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 410fe0c9a8be..e6a72f4b9dbe 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -26,10 +26,7 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { - flushIfServerless, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, -} from '@sentry/core/server'; +import { flushIfServerless } from '@sentry/core/server'; import { captureException, continueTrace, @@ -90,11 +87,6 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } - const tunnelResponse = handleServedTunnelRequest(ctx.request); - if (tunnelResponse) { - return tunnelResponse; - } - const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); // For static (prerendered) routes, we only want to inject the parametrized route meta tags diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index fd9f0dc2a139..b205af292372 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -2,7 +2,6 @@ import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } f import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import * as SentryCoreServer from '@sentry/core/server'; import * as SentryNode from '@sentry/node'; import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -34,27 +33,6 @@ const STATIC_REQUEST_CONTEXT = { url: new URL('https://myDomain.io/users/'), }; -describe('sentryMiddleware with the managed tunnel', () => { - it('answers tunnel requests without calling the rest of the middleware chain', async () => { - const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); - const tunnelResponse = new Response('forwarded'); - const matcherSpy = vi - .spyOn(SentryCoreServer, '_INTERNAL_handleServedTunnelRequest') - .mockReturnValue(Promise.resolve(tunnelResponse)); - const next = vi.fn(); - const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); - - const response = await handleRequest()({ ...DYNAMIC_REQUEST_CONTEXT, request } as unknown as APIContext, next); - - expect(response).toBe(tunnelResponse); - expect(matcherSpy).toHaveBeenCalledWith(request); - expect(next).not.toHaveBeenCalled(); - - getClientSpy.mockRestore(); - matcherSpy.mockRestore(); - }); -}); - describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index edc7368060ce..0c6856ead619 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,7 +1,4 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, -} from '@sentry/core/server'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { definePlugin } from 'nitro'; import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; @@ -9,13 +6,8 @@ import { captureErrorHook } from '../hooks/captureErrorHook'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); - // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block - // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, - // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it, - // unless a framework serves the app under another prefix: `@sentry/solidstart` sets `sentryTunnelBasePath` to Vite's - // `base`. + // The runtime answers managed tunnel requests, under Nitro's `baseURL` unless a framework serves the app under + // another prefix: `@sentry/solidstart` sets `sentryTunnelBasePath` to Vite's `base`. const runtimeConfig = useRuntimeConfig() as { app?: { baseURL?: string }; sentryTunnelBasePath?: string }; setManagedTunnelDefaults({ basePath: runtimeConfig.sentryTunnelBasePath || runtimeConfig.app?.baseURL }); - const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index 89b1a04e8958..a6e5f233c971 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,33 +1,12 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, - _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, -} from '@sentry/core/server'; -import { eventHandler, toWebRequest } from 'h3'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import type { NitroAppPlugin } from 'nitropack'; import { useRuntimeConfig } from 'nitropack/runtime'; /** - * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2), from the first layer of the h3 app. That runs - * before route rules and the app's `server/middleware`. + * Serves the managed tunnel under Nuxt's `app.baseURL` on Nuxt v3 and v4 (Nitro v2). The runtime answers the + * requests. */ -export default (nitroApp => { - // The first layer sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. +export default (() => { // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. setManagedTunnelDefaults({ basePath: useRuntimeConfig().app?.baseURL }); - - // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule - // that layer throws for writing headers on the sent response. - nitroApp.h3App.stack.unshift({ - route: '', - handler: eventHandler(event => { - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isServedTunnelRequest({ method: event.method, url: event.path })) { - return undefined; - } - - // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. - return handleServedTunnelRequest(toWebRequest(event)); - }), - }); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index ae4b451edc32..0741ffa3bccd 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,20 +1,11 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, -} from '@sentry/core/server'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { useRuntimeConfig } from 'nitro/runtime-config'; -import type { NitroAppPlugin, NitroApp } from 'nitro/types'; +import type { NitroAppPlugin } from 'nitro/types'; /** - * Answers managed tunnel requests on Nuxt v5+ (Nitro v3+). + * Serves the managed tunnel under Nuxt's `app.baseURL` on Nuxt v5+ (Nitro v3+). The runtime answers the requests. */ -export default ((nitroApp: NitroApp) => { - // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would - // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes - // first, before route rules and middleware. - // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. `useRuntimeConfig` also picks - // up a `NUXT_APP_BASE_URL` set at runtime. +export default (() => { + // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); - const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 0a7f358f1182..7401a6c1596d 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -120,7 +120,7 @@ function setupDeprecatedInjectMode( } /** - * Adds the plugin that answers the managed tunnel. The Cloudflare setup has no server config file, so it skips + * Adds the plugin that sets the managed tunnel's base path. The Cloudflare setup has no server config file, so it skips * `setupNitroServer` and adds this on its own. */ export function addTunnelPlugin(serverApi: ServerApi): void { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 18b03a6d2c13..f7dd1cfe04d9 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,70 +1,15 @@ -import { debug, getClient } from '@sentry/core'; -import { - _INTERNAL_getTunnelPaths as getTunnelPaths, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, -} from '@sentry/core/server'; -import { DEBUG_BUILD } from '../common/debug-build'; - -// Never matches a request, for when the managed tunnel is off. -const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; /** - * The resource routes that serve the managed tunnel, one per tunnel path. `sentryReactRouter()` adds them to the - * server build, since React Router has no request hook to answer from and a route matches only one path. Not meant - * to be used directly. + * Serves the managed tunnel under the app's `basename`, which React Router leaves in the request URL. The runtime + * answers the requests, so it adds no routes. `sentryReactRouter()` calls it from the server build. Not meant to be + * used directly. * - * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. + * @param basename The app's `basename`. */ export function createSentryTunnelRoutes(basename?: string): Record> { // Runs when the server build loads. Clients created later, e.g. per request on Cloudflare, start with the basename. setManagedTunnelDefaults({ basePath: basename }); - // `init` runs from `--import` before the server build loads, so the client and its tunnel paths are usually known. - if (!getClient()) { - return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', getLazyTunnelPath) }; - } - - return Object.fromEntries( - getTunnelPaths().map((path, index) => { - const id = `sentry-tunnel-${index}`; - - return [id, makeTunnelRoute(id, () => path)]; - }), - ); -} - -/** - * The path of the single route added when `init` runs after the server build loads, e.g. per request on Cloudflare. - * React Router reads it on its first request and keeps it until the server restarts. - */ -function getLazyTunnelPath(): string { - if (!getClient()) { - DEBUG_BUILD && debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); - return DISABLED_TUNNEL_PATH; - } - - const [path, ...otherPaths] = getTunnelPaths(); - if (otherPaths.length) { - DEBUG_BUILD && debug.warn(`Sentry.init ran after the server build loaded, so only ${path} is served.`); - } - - return path ?? DISABLED_TUNNEL_PATH; -} - -function makeTunnelRoute(id: string, getPath: () => string): Record { - return { - id, - parentId: undefined, - index: undefined, - caseSensitive: undefined, - get path(): string { - return getPath(); - }, - module: { - action: ({ request }: { request: Request }) => - handleServedTunnelRequest(request) ?? new Response(null, { status: 404 }), - loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), - }, - }; + return {}; } diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts index 5d2c12cb244c..b3cda4d94507 100644 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -3,8 +3,8 @@ import type { Plugin } from 'vite'; const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; /** - * Adds the managed tunnel routes to the server build. They only serve requests when `serveTunnelIntegration` is set - * up. The server build exports the app's `basename`, which the route needs to match the request URL. + * Passes the app's `basename` from the server build to the managed tunnel, which serves under it. Only applies when + * `serveTunnelIntegration` is set up. */ export function makeTunnelRoutePlugin(): Plugin { return { diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts deleted file mode 100644 index 38b2a7c7900b..000000000000 --- a/packages/react-router/test/server/tunnelRoute.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSentryTunnelRoutes } from '../../src/server/tunnelRoute'; - -const DSN = 'https://public@o1.ingest.sentry.io/1337'; - -type TunnelRoute = { - path: string; - parentId: undefined; - module: { action: (args: { request: Request }) => Promise | Response; loader: () => Response }; -}; - -function firstRoute(basename?: string): TunnelRoute { - return Object.values(createSentryTunnelRoutes(basename))[0] as TunnelRoute; -} - -/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ -function setClient(managedTunnel: object | undefined): void { - const options = { dsn: DSN }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - if (managedTunnel) { - serveTunnelIntegration(managedTunnel).setup!(client); - } -} - -const ENVELOPE = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - -describe('createSentryTunnelRoutes', () => { - afterEach(() => { - getCurrentScope().setClient(undefined); - _INTERNAL_resetManagedTunnelDefaults(); - vi.unstubAllGlobals(); - }); - - it('is a top-level route at the tunnel path when `serveTunnelIntegration` is set up', () => { - setClient({}); - const route = firstRoute(); - - expect(route.parentId).toBeUndefined(); - expect(route.path).toBe(getTunnelPath(DSN)); - }); - - it('adds no route when the tunnel is off, and a path that never matches when Sentry is not initialized yet', () => { - const route = firstRoute(); - const pathWithoutClient = route.path; - setClient(undefined); - - expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); - expect(route.path).toBe('/__sentry-tunnel-disabled__'); - expect(createSentryTunnelRoutes()).toEqual({}); - }); - - it('forwards tunnel POSTs and rejects other methods', async () => { - setClient({}); - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve(new Response('forwarded'))), - ); - const route = firstRoute(); - const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { - method: 'POST', - body: ENVELOPE, - headers: { 'content-length': String(ENVELOPE.length) }, - }); - - expect(await (await route.module.action({ request })).text()).toBe('forwarded'); - expect(route.module.loader().status).toBe(405); - }); - - it('serves the tunnel under `basename`, which React Router leaves in the request URL', async () => { - setClient({}); - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve(new Response('forwarded'))), - ); - const route = firstRoute('/app/'); - const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { - method: 'POST', - body: ENVELOPE, - headers: { 'content-length': String(ENVELOPE.length) }, - }); - - // React Router matches the route under `basename` itself, so the path stays relative to it. - expect(route.path).toBe(getTunnelPath(DSN)); - expect(await (await route.module.action({ request })).text()).toBe('forwarded'); - }); -}); diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index f63578e2f0f9..0094d56e7594 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,7 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; import { consoleSandbox } from '@sentry/core'; -import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import type { OrchestrionChannelContext } from '@sentry/server-utils'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; @@ -59,7 +58,7 @@ function subscribeToCreateRouter(): void { } /** - * Report errors from any fetch handler, router or not, and answer managed tunnel requests before it runs. + * Report errors from any fetch handler, router or not. * The middleware only sees failures inside a router; this is the only hook that covers a plain handler. * No abort guard is needed, because the listener drops aborted requests before calling `onError`. */ @@ -72,7 +71,6 @@ function subscribeToCreateRequestListener(): void { start(data) { try { injectOnError(ensureOptions(data.arguments, 1)); - wrapFetchHandler(data.arguments); } catch { // Ignored on purpose. } @@ -84,21 +82,6 @@ function subscribeToCreateRequestListener(): void { }); } -/** - * Answers managed tunnel requests before the app's fetch handler, so code the app runs in front of its router never - * sees them. The router middleware still answers them for apps served some other way. - */ -function wrapFetchHandler(args: unknown[]): void { - const handler = args[0]; - if (typeof handler !== 'function') { - return; - } - - args[0] = function sentryFetchHandler(this: unknown, request: Request, ...rest: unknown[]): unknown { - return handleServedTunnelRequest(request) ?? handler.call(this, request, ...rest); - }; -} - function injectOnError(raw: Record | undefined): void { if (!raw || markInjected(raw)) { return; diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 4ca54f648383..f2ce59c48f01 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -10,7 +10,6 @@ import { updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; -import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; @@ -26,11 +25,6 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { - const tunnelResponse = handleServedTunnelRequest(context.request); - if (tunnelResponse) { - return tunnelResponse; - } - const isolationScope = getIsolationScope(); isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) }); diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index 85133e8b3ecd..cf22efc91609 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -144,33 +144,3 @@ describe('the createRequestListener error hook', () => { expect(args[1]).toEqual(expect.objectContaining({ onError: expect.any(Function) })); }); }); - -describe('the createRequestListener tunnel', () => { - beforeAll(() => { - instrumentRemixV3(); - }); - - it("answers a managed tunnel request without calling the app's handler", async () => { - const appHandler = vi.fn(() => new Response('app')); - const args: unknown[] = [appHandler]; - - publishCreateRequestListener(args); - - expect(await (args[0] as (request: Request) => unknown)(new Request(TUNNEL_URL, { method: 'POST' }))).toBe( - tunnelResponse, - ); - expect(appHandler).not.toHaveBeenCalled(); - }); - - it("hands every other request to the app's handler", () => { - const appResponse = new Response('app'); - const appHandler = vi.fn(() => appResponse); - const args: unknown[] = [appHandler]; - const request = new Request('http://localhost/page'); - - publishCreateRequestListener(args); - - expect((args[0] as (request: Request, client: unknown) => unknown)(request, 'client')).toBe(appResponse); - expect(appHandler).toHaveBeenCalledWith(request, 'client'); - }); -}); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index e0f1866e78fd..8a8e1e5db21b 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -40,19 +40,6 @@ describe('sentryRemixMiddleware', () => { handleServedTunnelRequest.mockReturnValue(undefined); }); - it('answers tunnel requests before route matching and the rest of the chain', async () => { - const tunnelResponse = new Response('forwarded'); - handleServedTunnelRequest.mockReturnValue(Promise.resolve(tunnelResponse)); - const next = vi.fn(); - const context = contextFor('http://x/abc123'); - - const response = await sentryRemixMiddleware(matcherFor('/'))(context, next); - - expect(response).toBe(tunnelResponse); - expect(handleServedTunnelRequest).toHaveBeenCalledWith(context.request); - expect(next).not.toHaveBeenCalled(); - }); - it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }); const middleware = sentryRemixMiddleware(matcherFor('/users/:id')); diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index c6882b74082d..a6061e94d33e 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,37 +1,15 @@ import { GLOBAL_OBJ } from '@sentry/core'; -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, - _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, -} from '@sentry/core/server'; -import { eventHandler, toWebRequest } from 'h3'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import type { NitroAppPlugin } from 'nitropack'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; /** - * Answers managed tunnel requests on SolidStart v1 (Nitro v2), from the first layer of the h3 app. That runs before - * route rules, the app's middleware and routing. `withSentry` registers it. + * Serves the managed tunnel under the app's base path on SolidStart v1 (Nitro v2). The runtime answers the requests. + * `withSentry` registers it. */ -export default (nitroApp => { - // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. The server - // bundle's banner sets the global before this plugin runs. +export default (() => { + // The server bundle's banner sets the global before this plugin runs. setManagedTunnelDefaults({ basePath: (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], }); - - // Not the `request` hook: h3 still runs the first layer after a hook answered, so route rules would act on the - // sent response. - nitroApp.h3App.stack.unshift({ - route: '', - handler: eventHandler(event => { - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isServedTunnelRequest({ method: event.method, url: event.path })) { - return undefined; - } - - // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which - // aren't Node streams. A returned `Response` is sent and ends the request, `undefined` hands it on. - return handleServedTunnelRequest(toWebRequest(event)); - }), - }); }) satisfies NitroAppPlugin; diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts deleted file mode 100644 index 0f03cef43745..000000000000 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetManagedTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -import { createApp, eventHandler, setHeaders, toNodeListener } from 'h3'; -import { fetchNodeRequestHandler } from 'node-mock-http'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import tunnelPlugin from '../../src/nitro/tunnelPlugin'; - -const DSN = 'https://public@o1.ingest.sentry.io/1337'; - -type RequestHook = (event: unknown) => Promise; - -/** - * Calls an h3 app wired like Nitro v2's: the `request` hook runs in `onRequest`, and the route rules layer, here - * with a `headers` rule, comes before the app. Requests come in the way serverless presets pass them through - * `localFetch`, with node-mock-http's request, which isn't a Node stream. - */ -function fetchApp(url: string, init: RequestInit): Promise { - const requestHooks: RequestHook[] = []; - const app = createApp({ - onRequest: async event => { - for (const hook of requestHooks) { - await hook(event); - } - }, - onError: error => onError(error), - }); - app.use(eventHandler(event => setHeaders(event, { 'x-route-rule': '1' }))); - app.use(eventHandler(() => 'app')); - (tunnelPlugin as (nitroApp: unknown) => void)({ - h3App: app, - hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && requestHooks.push(fn) }, - }); - - return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); -} - -const onError = vi.fn(); - -describe('SolidStart v1 tunnel plugin', () => { - afterEach(() => { - getCurrentScope().setClient(undefined); - _INTERNAL_resetManagedTunnelDefaults(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - it('answers tunnel requests before route rules and the app with `serveTunnelIntegration`', async () => { - const options = { dsn: DSN }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - serveTunnelIntegration().setup!(client); - const sentryFetch = vi.fn((_url: string, _init: RequestInit) => Promise.resolve(new Response('forwarded'))); - vi.stubGlobal('fetch', sentryFetch); - const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - const response = await fetchApp(getTunnelPath(DSN)!, { - method: 'POST', - body: envelope, - headers: { 'content-length': String(envelope.length) }, - }); - - expect(await response.text()).toBe('forwarded'); - expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); - // A route rule that ran after the answer would fail to set headers on the sent response. - expect(response.headers.get('x-route-rule')).toBeNull(); - expect(onError).not.toHaveBeenCalled(); - }); - - it('serves no tunnel without `serveTunnelIntegration`', async () => { - const options = { dsn: DSN }; - getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); - const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - const response = await fetchApp(getTunnelPath(DSN)!, { - method: 'POST', - body: envelope, - headers: { 'content-length': String(envelope.length) }, - }); - - expect(await response.text()).toBe('app'); - }); - - it('leaves every other request to the app', async () => { - const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); - - expect(await response.text()).toBe('app'); - expect(response.headers.get('x-route-rule')).toBe('1'); - }); -}); diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index c9ad219f9b86..c5d4257f40aa 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -19,11 +19,7 @@ import { withIsolationScope, filterCollectedUrl, } from '@sentry/core'; -import { - flushIfServerless, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, -} from '@sentry/core/server'; +import { flushIfServerless, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { getTunnelBasePath } from '../common/tunnel'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -301,11 +297,6 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { - const tunnelResponse = handleServedTunnelRequest(input.event.request); - if (tunnelResponse) { - return tunnelResponse; - } - const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent; // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 1cfb18963193..bacf64fc0e38 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -4,7 +4,6 @@ import { getCapturedScopesOnSpan, getRootSpan, getSpanDescendants, spanIsSampled import * as SentryCore from '@sentry/core'; import { NodeClient, serveTunnelIntegration, setCurrentClient } from '@sentry/node'; import { _INTERNAL_resetManagedTunnelDefaults } from '@sentry/core/server'; -import { TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -146,16 +145,6 @@ describe('sentryHandle with span streaming', () => { describe('sentryHandle with the managed tunnel', () => { const DSN = 'https://public@o1.ingest.sentry.io/1337'; - function makeTunnelRequest(path: string): Request { - const body = SentryCore.serializeEnvelope(SentryCore.createEnvelope({ dsn: DSN }, [])) as string; - - return new Request(`http://localhost:3000${path}`, { - method: 'POST', - body, - headers: { 'content-length': String(body.length) }, - }); - } - beforeEach(() => { vi.stubGlobal( 'fetch', @@ -171,36 +160,6 @@ describe('sentryHandle with the managed tunnel', () => { _INTERNAL_resetManagedTunnelDefaults(); }); - it('answers tunnel requests without resolving the route', async () => { - const mockResolve = vi.fn(); - - const response = await sentryHandle()({ - event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), - resolve: mockResolve, - }); - - expect(await response.text()).toBe('forwarded'); - expect(mockResolve).not.toHaveBeenCalled(); - }); - - it('serves the tunnel under the base path `sentrySvelteKit()` set before the handle was created', async () => { - vi.stubGlobal(TUNNEL_BASE_PATH_GLOBAL, '/app'); - const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); - const handle = sentryHandle(); - - const underBasePath = await handle({ - event: mockEvent({ request: makeTunnelRequest(`/app${SentryCore.getTunnelPath(DSN)!}`), route: { id: null } }), - resolve: mockResolve, - }); - const atRoot = await handle({ - event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), - resolve: mockResolve, - }); - - expect(await underBasePath.text()).toBe('forwarded'); - expect(await atRoot.text()).toBe('app'); - }); - it('resolves every other request', async () => { const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 1d4d09b80553..86a3eb5ce56f 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -3,8 +3,6 @@ import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; import { _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import type { ServerEntry } from './wrapFetchWithSentry'; @@ -99,8 +97,9 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) } /** - * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this - * to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes on. + * Enables the managed tunnel for the server entry, which the runtime then answers. The `sentryTanstackStart` Vite + * plugin applies this to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes + * on. * * `tunnelRoute` enables the managed tunnel on every server client the same way `serveTunnelIntegration` does, unless * that integration is set up too, and the tunnel is then served from the client's config like in every other SDK. @@ -112,33 +111,9 @@ export function wrapServerEntryWithSentryTunnel( tunnelRoute?: ServerTunnelOptions, basePath?: string, ): T { - // Runs when the server entry loads, before any request. Requests still carry Start's base path here, so the tunnel - // is served under it. + // Runs when the server entry loads, before any request. Requests still carry Start's base path, so the tunnel is + // served under it. setManagedTunnelDefaults({ tunnel: tunnelRoute, forward: forwardTunnelRequest, basePath }); - // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served - // by the wrapped default server entry it calls. - if (typeof entry === 'function') { - return entry; - } - - // Keeps the prototype, so a class instance entry keeps its other handlers, like `scheduled` or `queue`. `fetch` is - // defined rather than assigned, which a read-only `fetch` on a frozen prototype would reject. - return Object.create(Object.getPrototypeOf(entry), { - ...Object.getOwnPropertyDescriptors(entry), - fetch: { - // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. - value: (request: Request, ...rest: unknown[]) => { - // Start would render the app for any other method on a tunnel path, running its root loaders. - if (request.method !== 'POST' && isServedTunnelRequest({ method: 'POST', url: request.url })) { - return new Response(null, { status: 405, headers: { Allow: 'POST' } }); - } - - return handleServedTunnelRequest(request) ?? entry.fetch(request, ...rest); - }, - writable: true, - configurable: true, - enumerable: true, - }, - }) as T; + return entry; } diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index b35224c6e277..12f85045516b 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -38,15 +38,6 @@ describe('wrapServerEntryWithSentryTunnel', () => { vi.clearAllMocks(); }); - it('answers the tunnel path when `serveTunnelIntegration` is set up, without calling the app', async () => { - setClient({}); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); - - expect(await response.text()).toBe('forwarded'); - expect(appFetch).not.toHaveBeenCalled(); - }); - it('hands every request to the app when the managed tunnel is off', async () => { setClient(undefined); @@ -56,40 +47,6 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(sentryFetch).not.toHaveBeenCalled(); }); - it('serves the tunnel with build-time options even without `serveTunnelIntegration`', async () => { - setClient(undefined); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( - makeEnvelopeRequest(TUNNEL_URL), - ); - - expect(await response.text()).toBe('forwarded'); - }); - - it('rejects other methods on a fixed `tunnelRoute` path instead of rendering the app', async () => { - setClient(undefined); - const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; - const wrapped = wrapServerEntryWithSentryTunnel(entry, { path: '/monitor' }); - - const response = await wrapped.fetch(new Request('http://localhost:3000/monitor')); - - expect(response.status).toBe(405); - expect(response.headers.get('allow')).toBe('POST'); - expect(appFetch).not.toHaveBeenCalled(); - expect(await (await wrapped.fetch(new Request('http://localhost:3000/other'))).text()).toBe('app'); - }); - - it('rejects other methods on the DSN-derived tunnel path too', async () => { - setClient({}); - const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; - - const response = await wrapServerEntryWithSentryTunnel(entry).fetch(new Request(TUNNEL_URL)); - - expect(response.status).toBe(405); - expect(response.headers.get('allow')).toBe('POST'); - expect(appFetch).not.toHaveBeenCalled(); - }); - it('passes other requests and their options to the app', async () => { setClient({}); const request = makeEnvelopeRequest('http://localhost:3000/api/users'); @@ -110,39 +67,6 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(appFetch).toHaveBeenCalledWith(request, env, ctx); }); - it("keeps a class instance entry's prototype handlers, like a Worker's `scheduled`", async () => { - setClient({}); - class Handler { - public fetch(_request: Request): Response { - return new Response('app'); - } - - public scheduled(): string { - return 'scheduled'; - } - } - - const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); - - expect(wrapped.scheduled()).toBe('scheduled'); - expect(wrapped).toBeInstanceOf(Handler); - expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); - }); - - it('wraps an entry whose prototype is frozen', async () => { - setClient({}); - class Handler { - public fetch(_request: Request): Response { - return new Response('app'); - } - } - Object.freeze(Handler.prototype); - - const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); - - expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); - }); - it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { class Worker { public fetch(): Response { @@ -166,35 +90,4 @@ describe('wrapServerEntryWithSentryTunnel', () => { ), ).toBe(true); }); - - it('keeps the options of `serveTunnelIntegration` over `tunnelRoute`', async () => { - setClient({ path: '/from-init' }); - const options = getCurrentScope().getClient()!.getOptions(); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, { path: '/from-build' }).fetch( - makeEnvelopeRequest('http://localhost:3000/from-init'), - ); - - expect(options._managedTunnel).toEqual({ path: '/from-init' }); - expect(await response.text()).toBe('forwarded'); - }); - - it('answers the tunnel under the base path Start routes on, and only there', async () => { - setClient({}); - const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); - - expect( - await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/app${getTunnelPath(DSN)}`))).text(), - ).toBe('forwarded'); - expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('app'); - }); - - it('keeps a base path `serveTunnelIntegration` set', async () => { - setClient({ basePath: '/custom' }); - const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); - - expect( - await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/custom${getTunnelPath(DSN)}`))).text(), - ).toBe('forwarded'); - }); }); From dabb35281000fae63b7d9683c2db17aa8e472d46 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 12:47:44 -0400 Subject: [PATCH 05/19] ref: Install the runtime tunnel hooks in `init` The Node, Bun and Deno SDKs install their tunnel hooks in `init` rather than from `serveTunnelIntegration`, so a tunnel a framework SDK enables, like TanStack Start's `tunnelRoute`, is answered too. The hooks answer nothing unless the tunnel is on, so `serveTunnelIntegration` is core's again on every runtime. --- packages/bun/src/index.ts | 2 +- packages/bun/src/sdk.ts | 3 + .../serveTunnel.ts => tunnelServer.ts} | 52 +++------------- packages/deno/src/index.ts | 2 +- packages/deno/src/sdk.ts | 3 + .../serveTunnel.ts => tunnelServer.ts} | 59 +++++-------------- packages/nextjs/src/index.types.ts | 2 - packages/node/src/index.ts | 2 +- packages/node/src/integrations/serveTunnel.ts | 49 --------------- packages/node/src/sdk/index.ts | 3 + packages/node/src/sdk/tunnelServer.ts | 20 +++++++ packages/sveltekit/src/index.types.ts | 2 +- 12 files changed, 55 insertions(+), 144 deletions(-) rename packages/bun/src/{integrations/serveTunnel.ts => tunnelServer.ts} (79%) rename packages/deno/src/{integrations/serveTunnel.ts => tunnelServer.ts} (60%) delete mode 100644 packages/node/src/integrations/serveTunnel.ts create mode 100644 packages/node/src/sdk/tunnelServer.ts diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index bcf340d6ac7c..5ff613985c91 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -171,6 +171,7 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + serveTunnelIntegration, getTunnelPath, handleTunnelRequest, handleTunnelRequestIfMatched, @@ -219,7 +220,6 @@ export { getInstrumentedModuleNames, } from '@sentry/node'; export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; -export { serveTunnelIntegration } from './integrations/serveTunnel'; export { captureConsoleIntegration, diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 96be2997ffab..2a70df255b1c 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -28,6 +28,7 @@ import { fetchIntegration } from './integrations/fetch'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; import { bunHttpServerIntegration } from './integrations/bunHttpServer'; +import { instrumentTunnelServers } from './tunnelServer'; import { getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; /** @@ -145,6 +146,8 @@ function _init( ): NodeClient | undefined { applySdkMetadata(userOptions, 'bun'); + instrumentTunnelServers(); + const options = { ...userOptions, platform: 'javascript', diff --git a/packages/bun/src/integrations/serveTunnel.ts b/packages/bun/src/tunnelServer.ts similarity index 79% rename from packages/bun/src/integrations/serveTunnel.ts rename to packages/bun/src/tunnelServer.ts index 623d9b854eec..a03a38a657e5 100644 --- a/packages/bun/src/integrations/serveTunnel.ts +++ b/packages/bun/src/tunnelServer.ts @@ -1,12 +1,10 @@ import * as http from 'node:http'; import * as https from 'node:https'; -import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; -import { _INTERNAL_normalizeTunnelBasePath, debug, defineIntegration, getClient } from '@sentry/core'; +import { _INTERNAL_normalizeTunnelBasePath, debug, getClient } from '@sentry/core'; import { _INTERNAL_getTunnelPaths as getTunnelPaths, _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, - serveTunnelIntegration as coreServeTunnelIntegration, } from '@sentry/core/server'; import type { Server } from 'bun'; @@ -16,49 +14,15 @@ type BunHandler = (request: Request, server: Server) => Response | Promise { - const integration = coreServeTunnelIntegration(options); - - return { - ...integration, - setupOnce() { - if (options.intercept === false) { - return; - } - - patchBunServe(); - patchHttpServer(); - }, - }; -}) satisfies IntegrationFn; - /** - * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. - * - * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your - * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. - * - * `Bun.serve` (including a default-exported server) and `node:http` servers answer the tunnel requests before the app - * sees them. To serve them from your own code instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. - * - * By default only envelopes for the server's own DSN are forwarded. - * - * @example - * ```js - * // Server - * Sentry.init({ - * dsn: '__DSN__', - * integrations: [Sentry.serveTunnelIntegration()], - * }); - * - * // Browser - * Sentry.init({ - * dsn: '__DSN__', - * tunnel: true, - * }); - * ``` + * Lets `Bun.serve` (including a default-exported server) and `node:http` servers answer managed tunnel requests + * before the app sees them. Nothing is answered unless the tunnel is on, through `serveTunnelIntegration` or a + * framework SDK. A server started before `init` isn't covered. */ -export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); +export function instrumentTunnelServers(): void { + patchBunServe(); + patchHttpServer(); +} /** * Answers tunnel requests ahead of every `Bun.serve` app, including after `server.reload`. Bun starts a diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 30079ef911b8..d26507f6988a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -114,11 +114,11 @@ export { export { handleTunnelRequestIfMatched, isTunnelRequest, + serveTunnelIntegration, trpcMiddleware, wrapMcpServerWithSentry, } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; -export { serveTunnelIntegration } from './integrations/serveTunnel'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 96af20a57b87..ffeb19027aa0 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -16,6 +16,7 @@ import { } from '@sentry/core'; import { detectOrchestrionSetup, getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; import { DenoClient } from './client'; +import { instrumentTunnelServers } from './tunnelServer'; import { nodeStackLineParser } from '@sentry/core/server'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; import { denoContextIntegration } from './integrations/context'; @@ -123,6 +124,8 @@ function _init(options: DenoOptions, getDefaultIntegrationsImpl: (options: Optio // result of the first `init` for any reused options object. const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(options); + instrumentTunnelServers(); + const clientOptions: ServerRuntimeClientOptions = { ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), diff --git a/packages/deno/src/integrations/serveTunnel.ts b/packages/deno/src/tunnelServer.ts similarity index 60% rename from packages/deno/src/integrations/serveTunnel.ts rename to packages/deno/src/tunnelServer.ts index dd618921af09..a2dd95dbd258 100644 --- a/packages/deno/src/integrations/serveTunnel.ts +++ b/packages/deno/src/tunnelServer.ts @@ -1,61 +1,30 @@ import { subscribe } from 'node:diagnostics_channel'; -import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; -import { debug, defineIntegration } from '@sentry/core'; +import { debug } from '@sentry/core'; import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, HTTP_ON_SERVER_REQUEST, - serveTunnelIntegration as coreServeTunnelIntegration, } from '@sentry/core/server'; type ServeHandler = (request: Request, info: Deno.ServeHandlerInfo) => Response | Promise; let hasPatchedDenoServe = false; - -const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { - const integration = coreServeTunnelIntegration(options); - - return { - ...integration, - setupOnce() { - if (options.intercept === false) { - return; - } - - // `node:http` servers publish the same channel on Deno as on Node. - subscribe(HTTP_ON_SERVER_REQUEST, instrumentTunnelServer); - patchDenoServe(); - }, - }; -}) satisfies IntegrationFn; +let isSubscribed = false; /** - * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. - * - * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your - * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. - * - * `Deno.serve` (including `deno serve`) and `node:http` servers answer the tunnel requests before the app sees them. - * To serve them from your own code instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. - * - * By default only envelopes for the server's own DSN are forwarded. - * - * @example - * ```js - * // Server - * Sentry.init({ - * dsn: '__DSN__', - * integrations: [Sentry.serveTunnelIntegration()], - * }); - * - * // Browser - * Sentry.init({ - * dsn: '__DSN__', - * tunnel: true, - * }); - * ``` + * Lets `Deno.serve` (including `deno serve`) and `node:http` servers answer managed tunnel requests before the app + * sees them. Nothing is answered unless the tunnel is on, through `serveTunnelIntegration` or a framework SDK. A + * `Deno.serve` server started before `init` isn't covered. */ -export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); +export function instrumentTunnelServers(): void { + if (!isSubscribed) { + isSubscribed = true; + // `node:http` servers publish the same channel on Deno as on Node. + subscribe(HTTP_ON_SERVER_REQUEST, instrumentTunnelServer); + } + + patchDenoServe(); +} /** * Answers tunnel requests ahead of every `Deno.serve` handler. `deno serve` starts the default export through the diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 659d0c23cfc8..baaed91053d2 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -25,8 +25,6 @@ export declare const contextLinesIntegration: typeof clientSdk.contextLinesInteg export declare const consoleIntegration: typeof serverSdk.consoleIntegration; // Node-only at runtime; the edge build exports an inert shim so named imports resolve in edge-compiled modules. export declare const pinoIntegration: typeof serverSdk.pinoIntegration; -// The Node one also answers tunnel requests on the `node:http` server; the edge one comes from core. -export declare const serveTunnelIntegration: typeof serverSdk.serveTunnelIntegration; // The client uses the `@sentry/core` span-start APIs while server and edge use the Cache // Components-aware ones from `common/utils/nextSpan`. Same signatures, but the star exports above // are ambiguous without these. diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index be7b23266a66..183069ccbadf 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -179,11 +179,11 @@ export { export { handleTunnelRequestIfMatched, isTunnelRequest, + serveTunnelIntegration, trpcMiddleware, wrapMcpServerWithSentry, } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; -export { serveTunnelIntegration } from './integrations/serveTunnel'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; diff --git a/packages/node/src/integrations/serveTunnel.ts b/packages/node/src/integrations/serveTunnel.ts deleted file mode 100644 index b72559adacbf..000000000000 --- a/packages/node/src/integrations/serveTunnel.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { defineIntegration } from '@sentry/core'; -import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; -import { - _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, - HTTP_ON_SERVER_REQUEST, - serveTunnelIntegration as coreServeTunnelIntegration, -} from '@sentry/core/server'; -import { subscribeDiagnosticsChannel } from '@sentry/server-utils'; - -const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { - const integration = coreServeTunnelIntegration(options); - - return { - ...integration, - setupOnce() { - if (options.intercept !== false) { - subscribeDiagnosticsChannel(HTTP_ON_SERVER_REQUEST, instrumentTunnelServer); - } - }, - }; -}) satisfies IntegrationFn; - -/** - * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. - * - * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your - * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. - * - * Every `node:http` server answers the tunnel requests before the app sees them, whatever framework runs on it. To - * serve them from your own code instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. - * - * By default only envelopes for the server's own DSN are forwarded. - * - * @example - * ```js - * // Server - * Sentry.init({ - * dsn: '__DSN__', - * integrations: [Sentry.serveTunnelIntegration()], - * }); - * - * // Browser - * Sentry.init({ - * dsn: '__DSN__', - * tunnel: true, - * }); - * ``` - */ -export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 57cf3871256a..855cb8403bbb 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -41,6 +41,7 @@ import { getSpotlightConfig } from '../utils/spotlight'; import { defaultStackParser, getSentryRelease } from './api'; import { NodeClient } from './client'; import { initOpenTelemetry } from './initOtel'; +import { instrumentTunnelServers } from './tunnelServer'; import { setupVercelKeepAlive } from './vercel'; /** @@ -133,6 +134,8 @@ function _init( applySdkMetadata(options, 'node'); + instrumentTunnelServers(); + // Enable debug logging before the client is created, so failure modes during setup (e.g. the // channel-injection registration finding no available Node hook API, or dep-resolution errors) // actually surface. `getClientOptions` resolves `debug` the same way for the client; resolving it diff --git a/packages/node/src/sdk/tunnelServer.ts b/packages/node/src/sdk/tunnelServer.ts new file mode 100644 index 000000000000..2060d94948ed --- /dev/null +++ b/packages/node/src/sdk/tunnelServer.ts @@ -0,0 +1,20 @@ +import { + _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, + HTTP_ON_SERVER_REQUEST, +} from '@sentry/core/server'; +import { subscribeDiagnosticsChannel } from '@sentry/server-utils'; + +let isSubscribed = false; + +/** + * Lets every `node:http` server answer managed tunnel requests before the app sees them, whatever framework runs on + * it. Nothing is answered unless the tunnel is on, through `serveTunnelIntegration` or a framework SDK. + */ +export function instrumentTunnelServers(): void { + if (isSubscribed) { + return; + } + + isSubscribed = true; + subscribeDiagnosticsChannel(HTTP_ON_SERVER_REQUEST, instrumentTunnelServer); +} diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 92bf43f90902..5ee8c6faca30 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -15,7 +15,7 @@ export * from './server'; export * from './worker'; // Use the ./server version of some functions that are also exported from ./worker -export { sentryHandle, serveTunnelIntegration } from './server'; +export { sentryHandle } from './server'; // Use the ./worker version of some functions that are also exported from ./server export { initCloudflareSentryHandle } from './worker'; From dec95b96a2c968294fe4c5492c735b5ae7155c2b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 12:47:44 -0400 Subject: [PATCH 06/19] feat(aws-serverless): Answer managed tunnel requests in `wrapHandler` Lambda runs no HTTP server, so `wrapHandler` answers tunnel requests from API Gateway, function URL and ALB events before the handler runs. --- packages/aws-serverless/src/sdk.ts | 12 ++ packages/aws-serverless/src/tunnel.ts | 165 ++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 packages/aws-serverless/src/tunnel.ts diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 539389f4d576..1f5077a7caca 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -4,6 +4,7 @@ import { captureException, captureMessage, flush, getCurrentScope, withScope } f import type { Context, Handler, StreamifyHandler } from 'aws-lambda'; import { performance } from 'perf_hooks'; import { types } from 'util'; +import { answerStreamingTunnelEvent, answerTunnelEvent } from './tunnel'; import { DEBUG_BUILD } from './debug-build'; import { markEventUnhandled } from './utils'; @@ -192,6 +193,12 @@ export function wrapHandler( : (handler as AsyncHandler>); return async (event: TEvent, context: Context) => { + // Lambda runs no HTTP server, so tunnel requests are answered here, before the handler. + const tunnelResult = answerTunnelEvent(event); + if (tunnelResult) { + return tunnelResult as Promise; + } + context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; timeoutWarningTimer = setupTimeoutWarning(context, options); @@ -244,6 +251,11 @@ function wrapStreamingHandler( responseStream: Parameters>[1], context: Context, ): Promise => { + const tunnelAnswer = answerStreamingTunnelEvent(event, responseStream); + if (tunnelAnswer) { + return tunnelAnswer as Promise; + } + context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; timeoutWarningTimer = setupTimeoutWarning(context, options); diff --git a/packages/aws-serverless/src/tunnel.ts b/packages/aws-serverless/src/tunnel.ts new file mode 100644 index 000000000000..3d8239b04a7d --- /dev/null +++ b/packages/aws-serverless/src/tunnel.ts @@ -0,0 +1,165 @@ +import { debug } from '@sentry/core'; +import { + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, +} from '@sentry/core/server'; +import { DEBUG_BUILD } from './debug-build'; + +/** + * The parts of an API Gateway (REST and HTTP API), function URL or ALB event the tunnel reads. + */ +interface HttpEvent { + version?: string; + httpMethod?: string; + path?: string; + rawPath?: string; + rawQueryString?: string; + requestContext?: { http?: { method?: string }; elb?: unknown }; + headers?: Record | null; + multiValueHeaders?: Record | null; + body?: string | null; + isBase64Encoded?: boolean; +} + +/** + * The result shape API Gateway, function URLs and ALB accept from a handler. + */ +interface HttpResult { + statusCode: number; + headers?: Record; + multiValueHeaders?: Record; + body: string; + isBase64Encoded: boolean; +} + +/** + * The streaming response helper the Lambda runtime provides. + */ +declare const awslambda: + | { + HttpResponseStream: { + from( + stream: unknown, + metadata: { statusCode: number; headers: Record }, + ): { + end(chunk?: Uint8Array): void; + }; + }; + } + | undefined; + +/** + * Answers a managed tunnel request that came in as an HTTP event, before the handler runs. Lambda runs no HTTP server, + * so the handler invocation is the only place to do it. Returns `undefined` for every other event. + * + * It never throws. Until a request is claimed, a failure hands the event to the handler. Once claimed, it ends in a + * 502 instead. + */ +export function answerTunnelEvent(event: unknown): Promise | undefined { + const request = toTunnelRequest(event); + if (!request) { + return undefined; + } + + const handled = handleServedTunnelRequest(request); + if (!handled) { + return undefined; + } + + const httpEvent = event as HttpEvent; + + return handled + .then(response => toHttpResult(response, !!httpEvent.multiValueHeaders)) + .catch(() => ({ statusCode: 502, body: '', isBase64Encoded: false })); +} + +/** + * Answers a managed tunnel request for a streaming handler, writing the answer to its response stream. Returns + * `undefined` for every other event, or when the runtime has no `awslambda.HttpResponseStream`. + */ +export function answerStreamingTunnelEvent(event: unknown, responseStream: unknown): Promise | undefined { + if (typeof awslambda === 'undefined' || !awslambda?.HttpResponseStream) { + return undefined; + } + + const request = toTunnelRequest(event); + const handled = request && handleServedTunnelRequest(request); + if (!handled) { + return undefined; + } + + return handled + .then(async response => { + const body = new Uint8Array(await response.arrayBuffer()); + const stream = awslambda.HttpResponseStream.from(responseStream, { + statusCode: response.status, + headers: Object.fromEntries(response.headers), + }); + stream.end(body); + }) + .catch(error => { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to answer a streaming tunnel request', error); + }); +} + +/** + * Builds a `Request` from an HTTP event that targets the tunnel, or returns `undefined`. Matches on the method and + * path first, so no other event's body gets decoded. + */ +function toTunnelRequest(event: unknown): Request | undefined { + try { + if (!event || typeof event !== 'object') { + return undefined; + } + + const httpEvent = event as HttpEvent; + const method = httpEvent.requestContext?.http?.method ?? httpEvent.httpMethod; + const path = + typeof httpEvent.rawPath === 'string' + ? `${httpEvent.rawPath}${httpEvent.rawQueryString ? `?${httpEvent.rawQueryString}` : ''}` + : httpEvent.path; + if (!method || !path || !isServedTunnelRequest({ method, url: path })) { + return undefined; + } + + const headers = new Headers(); + for (const [name, value] of Object.entries(httpEvent.headers ?? {})) { + if (value !== undefined) { + headers.set(name, value); + } + } + for (const [name, values] of Object.entries(httpEvent.multiValueHeaders ?? {})) { + if (values?.length && !headers.has(name)) { + headers.set(name, values.join(', ')); + } + } + + // API Gateway base64-encodes binary bodies, like a compressed replay envelope. + const body = httpEvent.body + ? httpEvent.isBase64Encoded + ? Uint8Array.from(atob(httpEvent.body), char => char.charCodeAt(0)) + : new TextEncoder().encode(httpEvent.body) + : new Uint8Array(0); + // The tunnel needs the length, which an event doesn't always carry. + headers.set('content-length', String(body.length)); + + return new Request(`https://${headers.get('host') || 'localhost'}${path}`, { method, headers, body }); + } catch (error) { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to read a Lambda event', error); + + return undefined; + } +} + +async function toHttpResult(response: Response, multiValueHeaders: boolean): Promise { + const headers = Object.fromEntries(response.headers); + + return { + statusCode: response.status, + ...(multiValueHeaders + ? { multiValueHeaders: Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, [value]])) } + : { headers }), + body: await response.text(), + isBase64Encoded: false, + }; +} From 7f0d38e9cabe09d949ee3b40965897725230c4a2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 12:47:45 -0400 Subject: [PATCH 07/19] fix(nuxt): Give the Cloudflare request its body and support Nitro v3 `sentryCloudflareNitroPlugin` passes the body Nitro already read, so the runtime can answer tunnel requests. On Nitro v3, which has no `localFetch`, it wraps `fetch` instead of throwing. --- .../plugins/sentry-cloudflare.server.ts | 32 +++++++++++++++++++ .../src/runtime/utils/event-type-check.ts | 2 ++ 2 files changed, 34 insertions(+) diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 1893de6a57b1..e206afb555e8 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -1,3 +1,4 @@ +import type { ExecutionContext } from '@cloudflare/workers-types'; import type { CloudflareOptions } from '@sentry/cloudflare'; import { getDefaultIntegrations, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare'; import { wrapRequestHandler } from '@sentry/cloudflare/request'; @@ -47,6 +48,34 @@ export const sentryCloudflareNitroPlugin = (nitroApp: NitroApp): void => { const traceDataMap = new WeakMap>(); + // Nitro v3 (Nuxt 5) has no `localFetch`. It hands the Worker's own request to `fetch`, with `ctx` on it. + const nitroV3App = nitroApp as unknown as { localFetch?: unknown; fetch?: (request: Request) => Promise }; + const appFetch = nitroV3App.fetch; + if (typeof nitroV3App.localFetch !== 'function' && typeof appFetch === 'function') { + nitroV3App.fetch = request => { + setAsyncLocalStorageAsyncContextStrategy(); + + const cloudflareOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn; + const runtime = (request as Request & { runtime?: { cloudflare?: { context?: ExecutionContext } } }).runtime; + + return wrapRequestHandler( + { + options: { + defaultIntegrations: getDefaultIntegrations(cloudflareOptions), + _extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined, + ...cloudflareOptions, + }, + request, + context: runtime?.cloudflare?.context, + }, + () => appFetch(request), + ); + }; + nitroApp.hooks.hook('error', sentryCaptureErrorHook); + + return; + } + nitroApp.localFetch = new Proxy(nitroApp.localFetch, { async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) { setAsyncLocalStorageAsyncContextStrategy(); @@ -64,6 +93,9 @@ export const sentryCloudflareNitroPlugin = const request = new Request(url, { method: event.method, headers: event.headers, + // Nitro read the body before calling `localFetch`. The managed tunnel answers from this request, so it + // needs the body; a `GET` or `HEAD` request can't have one. + body: event.method === 'GET' || event.method === 'HEAD' ? undefined : event.body, // @ts-expect-error - 'cf' is a valid property in the RequestInit type for Cloudflare cf: getCfProperties(event), }); diff --git a/packages/nuxt/src/runtime/utils/event-type-check.ts b/packages/nuxt/src/runtime/utils/event-type-check.ts index 1da0d8b2677a..14447c072c64 100644 --- a/packages/nuxt/src/runtime/utils/event-type-check.ts +++ b/packages/nuxt/src/runtime/utils/event-type-check.ts @@ -5,6 +5,8 @@ interface EventBase { host: string; method: string; headers: Record; + /** The request body Nitro already read, e.g. a `Buffer`. */ + body?: BodyInit | null; } interface MinimalCloudflareProps { From 17424813a33eedd121988168957658935d8e5512 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 12:47:45 -0400 Subject: [PATCH 08/19] fix(astro): Wrap Cloudflare Pages builds with `withSentry` Pages builds get the same Worker entry wrap as Workers again, which answers managed tunnel requests. It was dropped in #19558 only to keep it out of the bundle. --- packages/astro/src/integration/index.ts | 6 +++++- packages/astro/test/integration/cloudflare.test.ts | 9 ++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 66b5de50861b..f56e4fa3bd1b 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -228,7 +228,11 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/ updateConfig({ vite: { - plugins: [sentryCloudflareNodeWarningPlugin()], + // A Pages build gets the same `withSentry` wrap as Workers, which answers managed tunnel requests. + plugins: + command !== 'dev' + ? [sentryCloudflareNodeWarningPlugin(), sentryCloudflareVitePlugin()] + : [sentryCloudflareNodeWarningPlugin()], ssr: { // @sentry/node is required in case we have 2 different @sentry/node // packages installed in the same project. diff --git a/packages/astro/test/integration/cloudflare.test.ts b/packages/astro/test/integration/cloudflare.test.ts index 95c98f59c3a6..32a5b1ff0793 100644 --- a/packages/astro/test/integration/cloudflare.test.ts +++ b/packages/astro/test/integration/cloudflare.test.ts @@ -176,7 +176,7 @@ describe('Cloudflare Pages vs Workers detection', () => { }); expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ - vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'] }), }); }); @@ -208,7 +208,7 @@ describe('Cloudflare Pages vs Workers detection', () => { }); expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ - vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'] }), }); }); @@ -270,7 +270,7 @@ directory = "./dist" }); }); - it('does not add Cloudflare Vite plugins for Pages production build', async () => { + it('wraps the Worker entry with `withSentry` for a Pages production build', async () => { getWranglerConfig.mockReturnValue({ filename: 'wrangler.json', content: JSON.stringify({ @@ -290,9 +290,8 @@ directory = "./dist" command: 'build', }); - // Check that sentryCloudflareVitePlugin is NOT in any of the calls expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ - vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'] }), }); }); From d1c549ac5a600c5fe9ea806a702d787f7fbc30c8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 13:36:04 -0400 Subject: [PATCH 09/19] feat(node): Answer managed tunnel requests on Lambda Lambda runs no HTTP server, so when `init` runs from a preload with the tunnel on, it redirects `_HANDLER` to a shim that answers tunnel requests from HTTP events before the handler. `@sentry/aws-serverless` keeps its own shim, and the handler resolution and tunnel helpers move to `@sentry/node` so both share them. --- .../src/integration/awslambda.ts | 62 +------------ .../aws-serverless/src/run-lambda-handler.ts | 45 +--------- packages/aws-serverless/src/sdk.ts | 15 +++- packages/node/package.json | 7 +- packages/node/rollup.npm.config.mjs | 22 +++++ packages/node/src/index.ts | 6 ++ .../src/lambda}/handlerResolution.ts | 47 ++++++++++ packages/node/src/lambda/redirectHandler.ts | 87 +++++++++++++++++++ .../src => node/src/lambda}/tunnel.ts | 38 +++++++- packages/node/src/run-lambda-handler.ts | 11 +++ packages/node/src/sdk/index.ts | 7 ++ 11 files changed, 240 insertions(+), 107 deletions(-) rename packages/{aws-serverless/src => node/src/lambda}/handlerResolution.ts (65%) create mode 100644 packages/node/src/lambda/redirectHandler.ts rename packages/{aws-serverless/src => node/src/lambda}/tunnel.ts (74%) create mode 100644 packages/node/src/run-lambda-handler.ts diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 94ddb92d00f6..7ddfcc01b796 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -1,8 +1,8 @@ import type { IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core'; +import { _INTERNAL_redirectLambdaHandler } from '@sentry/node'; import { createRequire } from 'node:module'; import { DEBUG_BUILD } from '../debug-build'; -import { parseHandlerString, resolveHandlerFile } from '../handlerResolution'; const SHIM_MODULE_ID = '@sentry/aws-serverless/run-lambda-handler'; @@ -20,65 +20,11 @@ function resolveShimFile(): string | undefined { } /** - * Redirects the Lambda runtime to a Sentry handler shim by rewriting the `_HANDLER` - * environment variable. The runtime reads `_HANDLER` only after all `--import`/`--require` - * preloads (and thus `Sentry.init()`) have run, so it loads the shim instead of the user's - * handler. The shim (`run-lambda-handler.mjs`) then loads the module referenced by - * `SENTRY_ORIGINAL_HANDLER`, wraps the user's handler and exports the wrapped version. - * - * Because the shim wraps the handler *value* (whatever the export resolves to), this also - * covers handlers that are re-exported, wrapped (e.g. middy), bundled, or streaming. + * Redirects the Lambda runtime to the Sentry handler shim (`run-lambda-handler.mjs`), which wraps the user's handler + * with the full Lambda instrumentation. See `_INTERNAL_redirectLambdaHandler` in `@sentry/node`. */ export function redirectLambdaHandler(): void { - const taskRoot = process.env.LAMBDA_TASK_ROOT; - const handlerString = process.env._HANDLER; - - // _HANDLER and LAMBDA_TASK_ROOT are always defined in Lambda but guard bail out if in the future this changes. - if (!taskRoot || !handlerString) { - DEBUG_BUILD && - debug.log('Skipping lambda handler redirect: no _HANDLER or LAMBDA_TASK_ROOT.', { taskRoot, handlerString }); - return; - } - - // Already redirected (e.g. `init()` ran twice, or the user redirected manually). - if (process.env.SENTRY_ORIGINAL_HANDLER) { - DEBUG_BUILD && debug.log('Skipping lambda handler redirect: SENTRY_ORIGINAL_HANDLER is already set.'); - return; - } - - // The runtime rejects handler strings containing '..'; leave them untouched so it - // surfaces its own error. - if (handlerString.includes('..')) { - return; - } - - const parsedHandler = parseHandlerString(handlerString); - if (!parsedHandler) { - DEBUG_BUILD && debug.warn('Invalid handler definition, not instrumenting the Lambda handler.', { handlerString }); - return; - } - - // Validate that the handler module actually exists before redirecting, so a broken - // handler configuration still produces the runtime's original error message. - const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); - if (!resolvedFile) { - DEBUG_BUILD && - debug.warn('Could not resolve the Lambda handler file, not instrumenting the Lambda handler.', { - handlerString, - }); - return; - } - - const shimFile = resolveShimFile(); - if (!shimFile) { - return; - } - - process.env.SENTRY_ORIGINAL_HANDLER = handlerString; - process.env._HANDLER = `${shimFile.replace(/\.mjs$/, '')}.handler`; - - DEBUG_BUILD && - debug.log('Redirected lambda handler.', { originalHandler: handlerString, newHandler: process.env._HANDLER }); + _INTERNAL_redirectLambdaHandler(resolveShimFile); } const AWS_LAMBDA_CONTEXT_FIELDS = [ diff --git a/packages/aws-serverless/src/run-lambda-handler.ts b/packages/aws-serverless/src/run-lambda-handler.ts index a37a3f025443..2c2cb07c1e4f 100644 --- a/packages/aws-serverless/src/run-lambda-handler.ts +++ b/packages/aws-serverless/src/run-lambda-handler.ts @@ -6,48 +6,7 @@ // exporting the instrumented handler. The runtime awaits the dynamic import of this file, // resolves the `handler` export, and invokes it for every event. import type { Handler } from 'aws-lambda'; -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; -import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; +import { _INTERNAL_loadOriginalLambdaHandler } from '@sentry/node'; import { instrumentHandler } from './instrumentHandler'; -function fail(message: string): never { - throw new Error( - `[Sentry] Failed to load the Lambda handler: ${message} If this error persists, remove the Sentry integration and contact us at https://github.com/getsentry/sentry-javascript/issues`, - ); -} - -const taskRoot = process.env.LAMBDA_TASK_ROOT; -const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; - -if (!taskRoot || !originalHandlerString) { - fail('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); -} - -const parsedHandler = parseHandlerString(originalHandlerString); -if (!parsedHandler) { - fail(`"${originalHandlerString}" is not a valid handler string.`); -} - -const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); -if (!resolvedFile) { - fail(`Could not find module "${parsedHandler.moduleName}" for handler "${originalHandlerString}".`); -} - -const handlerModule: Record = - resolvedFile.format === 'esm' - ? ((await import(pathToFileURL(resolvedFile.file).href)) as Record) - : (createRequire(import.meta.url)(resolvedFile.file) as Record); - -// Mirrors the AWS runtime's nested handler resolution (`_resolveHandler` in -// https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js), -// e.g. `index.nested.handler`. -const originalHandler = parsedHandler.functionPath - .split('.') - .reduce((nested, key) => (nested as Record | undefined)?.[key], handlerModule); - -if (typeof originalHandler !== 'function') { - fail(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); -} - -export const handler = instrumentHandler(originalHandler as Handler); +export const handler = instrumentHandler((await _INTERNAL_loadOriginalLambdaHandler()) as Handler); diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 1f5077a7caca..1f93ecd908fc 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -1,10 +1,17 @@ import type { Scope } from '@sentry/core'; import { debug } from '@sentry/core'; -import { captureException, captureMessage, flush, getCurrentScope, withScope } from '@sentry/node'; +import { + _INTERNAL_answerLambdaTunnelEvent, + _INTERNAL_answerStreamingLambdaTunnelEvent, + captureException, + captureMessage, + flush, + getCurrentScope, + withScope, +} from '@sentry/node'; import type { Context, Handler, StreamifyHandler } from 'aws-lambda'; import { performance } from 'perf_hooks'; import { types } from 'util'; -import { answerStreamingTunnelEvent, answerTunnelEvent } from './tunnel'; import { DEBUG_BUILD } from './debug-build'; import { markEventUnhandled } from './utils'; @@ -194,7 +201,7 @@ export function wrapHandler( return async (event: TEvent, context: Context) => { // Lambda runs no HTTP server, so tunnel requests are answered here, before the handler. - const tunnelResult = answerTunnelEvent(event); + const tunnelResult = _INTERNAL_answerLambdaTunnelEvent(event); if (tunnelResult) { return tunnelResult as Promise; } @@ -251,7 +258,7 @@ function wrapStreamingHandler( responseStream: Parameters>[1], context: Context, ): Promise => { - const tunnelAnswer = answerStreamingTunnelEvent(event, responseStream); + const tunnelAnswer = _INTERNAL_answerStreamingLambdaTunnelEvent(event, responseStream); if (tunnelAnswer) { return tunnelAnswer as Promise; } diff --git a/packages/node/package.json b/packages/node/package.json index 230d3fa3e277..6afae80898e3 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -32,6 +32,9 @@ "default": "./build/import-hook.mjs" } }, + "./run-lambda-handler": { + "default": "./build/run-lambda-handler.mjs" + }, "./vite": { "import": { "types": "./build/types/bundler-plugin/vite.d.ts", @@ -118,7 +121,9 @@ "{projectRoot}/build/cjs", "{projectRoot}/build/npm/esm", "{projectRoot}/build/npm/cjs", - "{projectRoot}/build/import-hook.mjs" + "{projectRoot}/build/import-hook.mjs", + "{projectRoot}/build/run-lambda-handler.mjs", + "{projectRoot}/build/run-lambda-handler.mjs.map" ] } } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 45f7f66a6df4..265ddb183a99 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -12,7 +12,29 @@ const [localVariablesWorkerConfig, getLocalVariablesBase64Code] = createWorkerCo 'build/esm/integrations/local-variables', ); +// The Lambda handler shim `init` redirects `_HANDLER` to for the managed tunnel. Standalone and ESM-only, since it +// uses top-level await to load the user's handler module. Bare imports stay external. +function makeLambdaHandlerShimConfig() { + const baseConfig = makeBaseNPMConfig({ + entrypoints: ['src/run-lambda-handler.ts'], + // Top-level await requires es2022. + esbuild: { target: 'es2022' }, + }); + + return { + ...baseConfig, + output: { + ...baseConfig.output, + dir: undefined, + file: 'build/run-lambda-handler.mjs', + format: 'esm', + preserveModules: false, + }, + }; +} + export default [ + makeLambdaHandlerShimConfig(), // The `@sentry/node/import` entry (`node --import @sentry/node/import app.js`), which registers // the orchestrion diagnostics-channel injection before the app loads. ...makeOrchestrionLoader('./build'), diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 183069ccbadf..734d13af7c20 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -184,6 +184,12 @@ export { wrapMcpServerWithSentry, } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; +export { + answerStreamingTunnelEvent as _INTERNAL_answerStreamingLambdaTunnelEvent, + answerTunnelEvent as _INTERNAL_answerLambdaTunnelEvent, +} from './lambda/tunnel'; +export { loadOriginalLambdaHandler as _INTERNAL_loadOriginalLambdaHandler } from './lambda/handlerResolution'; +export { redirectLambdaHandler as _INTERNAL_redirectLambdaHandler } from './lambda/redirectHandler'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; diff --git a/packages/aws-serverless/src/handlerResolution.ts b/packages/node/src/lambda/handlerResolution.ts similarity index 65% rename from packages/aws-serverless/src/handlerResolution.ts rename to packages/node/src/lambda/handlerResolution.ts index f9d386c3de7b..a2426b9e885c 100644 --- a/packages/aws-serverless/src/handlerResolution.ts +++ b/packages/node/src/lambda/handlerResolution.ts @@ -17,7 +17,9 @@ * limitations under the License. */ import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; export interface ParsedHandler { /** Directory portion of the handler string, e.g. `src/` for `src/index.handler`. */ @@ -117,3 +119,48 @@ function hasTypeModulePackageJson(dir: string): boolean { current = parent; } } + +/** + * Loads the handler the Lambda runtime would have loaded, from `SENTRY_ORIGINAL_HANDLER`. For the handler shims + * `_HANDLER` is redirected to, which run it as their own handler. + */ +export async function loadOriginalLambdaHandler(): Promise<(...args: unknown[]) => unknown> { + const taskRoot = process.env.LAMBDA_TASK_ROOT; + const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; + if (!taskRoot || !originalHandlerString) { + failToLoad('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); + } + + const parsedHandler = parseHandlerString(originalHandlerString); + if (!parsedHandler) { + failToLoad(`"${originalHandlerString}" is not a valid handler string.`); + } + + const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); + if (!resolvedFile) { + failToLoad(`Could not find module "${parsedHandler.moduleName}" for handler "${originalHandlerString}".`); + } + + const handlerModule: Record = + resolvedFile.format === 'esm' + ? ((await import(pathToFileURL(resolvedFile.file).href)) as Record) + : (createRequire(import.meta.url)(resolvedFile.file) as Record); + + // Mirrors the AWS runtime's nested handler resolution (`_resolveHandler` in + // https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js), + // e.g. `index.nested.handler`. + const originalHandler = parsedHandler.functionPath + .split('.') + .reduce((nested, key) => (nested as Record | undefined)?.[key], handlerModule); + if (typeof originalHandler !== 'function') { + failToLoad(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); + } + + return originalHandler as (...args: unknown[]) => unknown; +} + +function failToLoad(message: string): never { + throw new Error( + `[Sentry] Failed to load the Lambda handler: ${message} If this error persists, remove the Sentry integration and contact us at https://github.com/getsentry/sentry-javascript/issues`, + ); +} diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts new file mode 100644 index 000000000000..4b539f5e8b8f --- /dev/null +++ b/packages/node/src/lambda/redirectHandler.ts @@ -0,0 +1,87 @@ +import { debug } from '@sentry/core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DEBUG_BUILD } from '../debug-build'; +import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; + +/** + * Redirects the Lambda runtime to a Sentry handler shim by rewriting the `_HANDLER` + * environment variable. The runtime reads `_HANDLER` only after all `--import`/`--require` + * preloads (and thus `Sentry.init()`) have run, so it loads the shim instead of the user's + * handler. The shim (`run-lambda-handler.mjs`) then loads the module referenced by + * `SENTRY_ORIGINAL_HANDLER`, wraps the user's handler and exports the wrapped version. + * + * Because the shim wraps the handler *value* (whatever the export resolves to), this also + * covers handlers that are re-exported, wrapped (e.g. middy), bundled, or streaming. + */ +export function redirectLambdaHandler(resolveShimFile: () => string | undefined): void { + const taskRoot = process.env.LAMBDA_TASK_ROOT; + const handlerString = process.env._HANDLER; + + // _HANDLER and LAMBDA_TASK_ROOT are always defined in Lambda but guard bail out if in the future this changes. + if (!taskRoot || !handlerString) { + DEBUG_BUILD && + debug.log('Skipping lambda handler redirect: no _HANDLER or LAMBDA_TASK_ROOT.', { taskRoot, handlerString }); + return; + } + + // Already redirected (e.g. `init()` ran twice, or the user redirected manually). + if (process.env.SENTRY_ORIGINAL_HANDLER) { + DEBUG_BUILD && debug.log('Skipping lambda handler redirect: SENTRY_ORIGINAL_HANDLER is already set.'); + return; + } + + // The runtime rejects handler strings containing '..'; leave them untouched so it + // surfaces its own error. + if (handlerString.includes('..')) { + return; + } + + const parsedHandler = parseHandlerString(handlerString); + if (!parsedHandler) { + DEBUG_BUILD && debug.warn('Invalid handler definition, not instrumenting the Lambda handler.', { handlerString }); + return; + } + + // Validate that the handler module actually exists before redirecting, so a broken + // handler configuration still produces the runtime's original error message. + const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); + if (!resolvedFile) { + DEBUG_BUILD && + debug.warn('Could not resolve the Lambda handler file, not instrumenting the Lambda handler.', { + handlerString, + }); + return; + } + + const shimFile = resolveShimFile(); + if (!shimFile) { + return; + } + + process.env.SENTRY_ORIGINAL_HANDLER = handlerString; + process.env._HANDLER = `${shimFile.replace(/\.mjs$/, '')}.handler`; + + DEBUG_BUILD && + debug.log('Redirected lambda handler.', { originalHandler: handlerString, newHandler: process.env._HANDLER }); +} + +/** + * On Lambda, redirects the handler to the shim that answers managed tunnel requests before it, since Lambda runs no + * HTTP server. Only takes effect when `init` runs from a preload (`NODE_OPTIONS=--import`), before the runtime loads + * the handler. + */ +export function redirectLambdaHandlerForTunnel(): void { + redirectLambdaHandler(() => { + // The shim sits at `build/run-lambda-handler.mjs`, next to the `build/esm` and `build/cjs` this file is built into. + // Rollup converts `import.meta.url` for the CJS build. + const shimFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../run-lambda-handler.mjs'); + if (!fs.existsSync(shimFile)) { + DEBUG_BUILD && debug.warn(`Could not find ${shimFile}, not answering tunnel requests.`); + return undefined; + } + + return shimFile; + }); +} diff --git a/packages/aws-serverless/src/tunnel.ts b/packages/node/src/lambda/tunnel.ts similarity index 74% rename from packages/aws-serverless/src/tunnel.ts rename to packages/node/src/lambda/tunnel.ts index 3d8239b04a7d..fc4944b2b4f0 100644 --- a/packages/aws-serverless/src/tunnel.ts +++ b/packages/node/src/lambda/tunnel.ts @@ -3,7 +3,7 @@ import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, } from '@sentry/core/server'; -import { DEBUG_BUILD } from './debug-build'; +import { DEBUG_BUILD } from '../debug-build'; /** * The parts of an API Gateway (REST and HTTP API), function URL or ALB event the tunnel reads. @@ -163,3 +163,39 @@ async function toHttpResult(response: Response, multiValueHeaders: boolean): Pro isBase64Encoded: false, }; } + +// The symbols the Lambda runtime reads off a streaming handler (`awslambda.streamifyResponse`). +const HANDLER_STREAMING_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming'); +const HANDLER_HIGHWATERMARK_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); + +type LambdaHandler = (...args: unknown[]) => unknown; + +/** + * Wraps a Lambda handler so it answers managed tunnel requests before the handler runs, and passes every other event + * to it unchanged. Streaming handlers keep the symbols the runtime reads off them. + */ +export function wrapLambdaHandlerForTunnel(handler: LambdaHandler): LambdaHandler { + const handlerSymbols = handler as unknown as Record; + if (handlerSymbols[HANDLER_STREAMING_SYMBOL] === 'response') { + const streamingHandler = function (this: unknown, event: unknown, responseStream: unknown, ...rest: unknown[]) { + return answerStreamingTunnelEvent(event, responseStream) ?? handler.call(this, event, responseStream, ...rest); + }; + const streamingSymbols = streamingHandler as unknown as Record; + streamingSymbols[HANDLER_STREAMING_SYMBOL] = handlerSymbols[HANDLER_STREAMING_SYMBOL]; + streamingSymbols[HANDLER_HIGHWATERMARK_SYMBOL] = handlerSymbols[HANDLER_HIGHWATERMARK_SYMBOL]; + + return streamingHandler; + } + + // Keeps the handler's arity, which the runtime reads to tell a callback handler from an async one (and which the + // Node.js 24 runtime rejects). + if (handler.length > 2) { + return function (this: unknown, event: unknown, context: unknown, callback: unknown) { + return answerTunnelEvent(event) ?? handler.call(this, event, context, callback); + }; + } + + return function (this: unknown, event: unknown, context: unknown) { + return answerTunnelEvent(event) ?? handler.call(this, event, context); + }; +} diff --git a/packages/node/src/run-lambda-handler.ts b/packages/node/src/run-lambda-handler.ts new file mode 100644 index 000000000000..19f43ae8d2c1 --- /dev/null +++ b/packages/node/src/run-lambda-handler.ts @@ -0,0 +1,11 @@ +// The handler shim the AWS Lambda runtime loads in place of the user's handler when `init` redirected `_HANDLER` +// for the managed tunnel (see `lambda/redirectHandler.ts`). Lambda runs no HTTP server, so the handler invocation is +// the only place to answer tunnel requests before the app. +// +// It is built as a standalone, ESM-only `build/run-lambda-handler.mjs`, because it uses top-level await to load the +// user's handler module, which may itself be ESM. `@sentry/aws-serverless` has its own shim with the full Lambda +// instrumentation, and `init` doesn't redirect when that one is in place. +import { loadOriginalLambdaHandler } from './lambda/handlerResolution'; +import { wrapLambdaHandlerForTunnel } from './lambda/tunnel'; + +export const handler = wrapLambdaHandlerForTunnel(await loadOriginalLambdaHandler()); diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 855cb8403bbb..3055a04728b6 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -42,6 +42,7 @@ import { defaultStackParser, getSentryRelease } from './api'; import { NodeClient } from './client'; import { initOpenTelemetry } from './initOtel'; import { instrumentTunnelServers } from './tunnelServer'; +import { redirectLambdaHandlerForTunnel } from '../lambda/redirectHandler'; import { setupVercelKeepAlive } from './vercel'; /** @@ -185,6 +186,12 @@ function _init( client.init(); + // Lambda runs no HTTP server, so its handler is wrapped to answer tunnel requests. After `client.init()`, so the + // redirect of `@sentry/aws-serverless`, whose handler answers them too, goes first and this one stays out of it. + if (process.env.LAMBDA_TASK_ROOT && client.getOptions()._managedTunnel) { + redirectLambdaHandlerForTunnel(); + } + /*! rollup-include-cjs-only */ debug.log(`SDK initialized from CommonJS`); /*! rollup-include-cjs-only-end */ From b663d6c78ac059bf77ba4cf116e6f78d41d4c7b9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 16:47:00 -0400 Subject: [PATCH 10/19] fix(core): Share the managed tunnel state across copies of core A framework's server bundle can carry its own copy of `@sentry/core`, so the base path and `tunnelRoute` defaults it set never reached the runtime's copy that answers the tunnel. --- packages/core/src/carrier.ts | 8 ++++++++ packages/core/src/utils/managedTunnelDefaults.ts | 13 +++++++------ packages/core/src/utils/tunnel.ts | 15 +++++++++++---- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index cf48f39c7f4d..26018e928327 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -5,6 +5,8 @@ import type { Scope } from './scope'; import type { SegmentSpanCaptureStrategy } from './tracing/segmentSpanCaptureStrategy'; import type { SerializedLog } from './types/log'; import type { SerializedMetric } from './types/metric'; +import type { ManagedTunnelDefaults } from './utils/managedTunnelDefaults'; +import type { ServedTunnel } from './utils/tunnel'; import { SDK_VERSION } from './utils/version'; import { GLOBAL_OBJ } from './utils/worldwide'; @@ -50,6 +52,12 @@ export interface SentryCarrier { encodePolyfill?: (input: string) => Uint8Array; /** Overwrites TextDecoder used in `@sentry/core`, need for `react-native@0.73` and older */ decodePolyfill?: (input: Uint8Array) => string; + + /** + * Managed tunnel state, shared by every copy of `@sentry/core`: a framework's server bundle can carry its own copy, + * which sets the tunnel up for the runtime's copy that answers it. + */ + managedTunnel?: { served: WeakMap; defaults?: ManagedTunnelDefaults }; } /** diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index c78cdd55136d..8fa16219f705 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -3,6 +3,7 @@ import { getClient } from '../currentScopes'; import type { IgnoreSpanFilter, ServerTunnelOptions } from '../types/options'; import type { TunnelForwarder } from './tunnel'; import { + getManagedTunnelState, getServeTunnelForwarder, getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, @@ -12,7 +13,7 @@ import { /** * Managed tunnel settings a framework SDK knows outside `init`. */ -interface ManagedTunnelDefaults { +export interface ManagedTunnelDefaults { /** * Enables the managed tunnel when `init` didn't, e.g. from TanStack Start's `tunnelRoute` build option. Its base * path is `basePath`, so it has none of its own. @@ -27,8 +28,6 @@ interface ManagedTunnelDefaults { basePath?: string; } -let managedTunnelDefaults: ManagedTunnelDefaults | undefined; - const tunnelForwarders = new WeakMap(); // So re-applying defaults replaces a client's filters instead of adding more. @@ -39,8 +38,9 @@ const installedSpanFilters = new WeakMap(); * (Cloudflare) start with them. Settings from `init` win. Framework SDKs call this once at load. */ export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { - managedTunnelDefaults = { - ...managedTunnelDefaults, + const state = getManagedTunnelState(); + state.defaults = { + ...state.defaults, ...(defaults.tunnel && { tunnel: defaults.tunnel, forward: defaults.forward }), ...(defaults.basePath !== undefined && { basePath: defaults.basePath }), }; @@ -66,6 +66,7 @@ export function enableManagedTunnel( * {@link setManagedTunnelDefaults}. */ export function applyManagedTunnelDefaults(client: Client | undefined): void { + const managedTunnelDefaults = getManagedTunnelState().defaults; const options = client?.getOptions(); const tunnelOptions: ServerTunnelOptions | undefined = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; // A framework SDK can bundle its own copy of this module, which only reaches the forwarder through the client. @@ -98,5 +99,5 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void { * @internal */ export function _INTERNAL_resetManagedTunnelDefaults(): void { - managedTunnelDefaults = undefined; + getManagedTunnelState().defaults = undefined; } diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 1321c672ad3b..3f0c97bf9a50 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,5 +1,7 @@ import type { Client } from '../client'; import type { Integration } from '../types/integration'; +import type { SentryCarrier } from '../carrier'; +import { getGlobalSingleton } from '../carrier'; import { getClient } from '../currentScopes'; import { URL_PATH } from '@sentry/conventions/attributes'; import type { DsnComponents, DsnLike } from '../types/dsn'; @@ -18,13 +20,18 @@ export type TunnelForwarder = (request: Request, allowedDsns: Array) => /** * A server client's tunnel paths, base path, allowed DSNs and forwarder, computed once per client. */ -interface ServedTunnel extends TunnelMatch { +export interface ServedTunnel extends TunnelMatch { forward: TunnelForwarder; /** Whether the SDK answers tunnel requests itself, see `ServerTunnelOptions.intercept`. */ intercept: boolean; } -const servedTunnels = new WeakMap(); +/** + * The managed tunnel state every copy of this module shares. + */ +export function getManagedTunnelState(): NonNullable { + return getGlobalSingleton('managedTunnel', () => ({ served: new WeakMap() })); +} /** * The name of `serveTunnelIntegration`, which carries the forwarder. Kept here so this module doesn't import the @@ -48,7 +55,7 @@ export function setServedTunnel( forward, intercept: tunnelOptions.intercept !== false, }; - servedTunnels.set(client, servedTunnel); + getManagedTunnelState().served.set(client, servedTunnel); return servedTunnel; } @@ -72,7 +79,7 @@ function getServedTunnel(): ServedTunnel | undefined { return undefined; } - const servedTunnel = servedTunnels.get(client); + const servedTunnel = getManagedTunnelState().served.get(client); if (servedTunnel) { return servedTunnel; } From caba1541282ed23ff89e42dd3d3ca33abb040397 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 16:47:00 -0400 Subject: [PATCH 11/19] fix(node): Keep the Lambda handler loader out of the main entry Its dynamic `require` made webpack warn in Next.js builds. Only the handler shims need it. --- .../scripts/consistentExports.ts | 6 +++ .../aws-serverless/src/run-lambda-handler.ts | 48 ++++++++++++++++++- packages/node/src/index.ts | 5 +- packages/node/src/lambda/handlerResolution.ts | 47 ------------------ packages/node/src/lambda/loadHandler.ts | 48 +++++++++++++++++++ packages/node/src/run-lambda-handler.ts | 2 +- 6 files changed, 105 insertions(+), 51 deletions(-) create mode 100644 packages/node/src/lambda/loadHandler.ts diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 6bee27e997bc..3bf3ea1aa3f6 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -20,6 +20,12 @@ const NODE_EXPORTS_IGNORE = [ 'initWithoutDefaultIntegrations', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', + // Lambda helpers only needed by `@sentry/aws-serverless` + '_INTERNAL_answerLambdaTunnelEvent', + '_INTERNAL_answerStreamingLambdaTunnelEvent', + '_INTERNAL_parseLambdaHandlerString', + '_INTERNAL_redirectLambdaHandler', + '_INTERNAL_resolveLambdaHandlerFile', // not exported by bun 'nativeNodeFetchIntegration', ]; diff --git a/packages/aws-serverless/src/run-lambda-handler.ts b/packages/aws-serverless/src/run-lambda-handler.ts index 2c2cb07c1e4f..d4b077850e51 100644 --- a/packages/aws-serverless/src/run-lambda-handler.ts +++ b/packages/aws-serverless/src/run-lambda-handler.ts @@ -6,7 +6,51 @@ // exporting the instrumented handler. The runtime awaits the dynamic import of this file, // resolves the `handler` export, and invokes it for every event. import type { Handler } from 'aws-lambda'; -import { _INTERNAL_loadOriginalLambdaHandler } from '@sentry/node'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + _INTERNAL_parseLambdaHandlerString as parseHandlerString, + _INTERNAL_resolveLambdaHandlerFile as resolveHandlerFile, +} from '@sentry/node'; import { instrumentHandler } from './instrumentHandler'; -export const handler = instrumentHandler((await _INTERNAL_loadOriginalLambdaHandler()) as Handler); +function fail(message: string): never { + throw new Error( + `[Sentry] Failed to load the Lambda handler: ${message} If this error persists, remove the Sentry integration and contact us at https://github.com/getsentry/sentry-javascript/issues`, + ); +} + +const taskRoot = process.env.LAMBDA_TASK_ROOT; +const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; + +if (!taskRoot || !originalHandlerString) { + fail('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); +} + +const parsedHandler = parseHandlerString(originalHandlerString); +if (!parsedHandler) { + fail(`"${originalHandlerString}" is not a valid handler string.`); +} + +const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); +if (!resolvedFile) { + fail(`Could not find module "${parsedHandler.moduleName}" for handler "${originalHandlerString}".`); +} + +const handlerModule: Record = + resolvedFile.format === 'esm' + ? ((await import(pathToFileURL(resolvedFile.file).href)) as Record) + : (createRequire(import.meta.url)(resolvedFile.file) as Record); + +// Mirrors the AWS runtime's nested handler resolution (`_resolveHandler` in +// https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js), +// e.g. `index.nested.handler`. +const originalHandler = parsedHandler.functionPath + .split('.') + .reduce((nested, key) => (nested as Record | undefined)?.[key], handlerModule); + +if (typeof originalHandler !== 'function') { + fail(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); +} + +export const handler = instrumentHandler(originalHandler as Handler); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 734d13af7c20..deaaeee4db86 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -188,7 +188,10 @@ export { answerStreamingTunnelEvent as _INTERNAL_answerStreamingLambdaTunnelEvent, answerTunnelEvent as _INTERNAL_answerLambdaTunnelEvent, } from './lambda/tunnel'; -export { loadOriginalLambdaHandler as _INTERNAL_loadOriginalLambdaHandler } from './lambda/handlerResolution'; +export { + parseHandlerString as _INTERNAL_parseLambdaHandlerString, + resolveHandlerFile as _INTERNAL_resolveLambdaHandlerFile, +} from './lambda/handlerResolution'; export { redirectLambdaHandler as _INTERNAL_redirectLambdaHandler } from './lambda/redirectHandler'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; diff --git a/packages/node/src/lambda/handlerResolution.ts b/packages/node/src/lambda/handlerResolution.ts index a2426b9e885c..f9d386c3de7b 100644 --- a/packages/node/src/lambda/handlerResolution.ts +++ b/packages/node/src/lambda/handlerResolution.ts @@ -17,9 +17,7 @@ * limitations under the License. */ import * as fs from 'node:fs'; -import { createRequire } from 'node:module'; import * as path from 'node:path'; -import { pathToFileURL } from 'node:url'; export interface ParsedHandler { /** Directory portion of the handler string, e.g. `src/` for `src/index.handler`. */ @@ -119,48 +117,3 @@ function hasTypeModulePackageJson(dir: string): boolean { current = parent; } } - -/** - * Loads the handler the Lambda runtime would have loaded, from `SENTRY_ORIGINAL_HANDLER`. For the handler shims - * `_HANDLER` is redirected to, which run it as their own handler. - */ -export async function loadOriginalLambdaHandler(): Promise<(...args: unknown[]) => unknown> { - const taskRoot = process.env.LAMBDA_TASK_ROOT; - const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; - if (!taskRoot || !originalHandlerString) { - failToLoad('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); - } - - const parsedHandler = parseHandlerString(originalHandlerString); - if (!parsedHandler) { - failToLoad(`"${originalHandlerString}" is not a valid handler string.`); - } - - const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); - if (!resolvedFile) { - failToLoad(`Could not find module "${parsedHandler.moduleName}" for handler "${originalHandlerString}".`); - } - - const handlerModule: Record = - resolvedFile.format === 'esm' - ? ((await import(pathToFileURL(resolvedFile.file).href)) as Record) - : (createRequire(import.meta.url)(resolvedFile.file) as Record); - - // Mirrors the AWS runtime's nested handler resolution (`_resolveHandler` in - // https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js), - // e.g. `index.nested.handler`. - const originalHandler = parsedHandler.functionPath - .split('.') - .reduce((nested, key) => (nested as Record | undefined)?.[key], handlerModule); - if (typeof originalHandler !== 'function') { - failToLoad(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); - } - - return originalHandler as (...args: unknown[]) => unknown; -} - -function failToLoad(message: string): never { - throw new Error( - `[Sentry] Failed to load the Lambda handler: ${message} If this error persists, remove the Sentry integration and contact us at https://github.com/getsentry/sentry-javascript/issues`, - ); -} diff --git a/packages/node/src/lambda/loadHandler.ts b/packages/node/src/lambda/loadHandler.ts new file mode 100644 index 000000000000..6dd8ad6a9bd0 --- /dev/null +++ b/packages/node/src/lambda/loadHandler.ts @@ -0,0 +1,48 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; + +/** + * Loads the handler the Lambda runtime would have loaded, from `SENTRY_ORIGINAL_HANDLER`. For the handler shims + * `_HANDLER` is redirected to, which run it as their own handler. + */ +export async function loadOriginalLambdaHandler(): Promise<(...args: unknown[]) => unknown> { + const taskRoot = process.env.LAMBDA_TASK_ROOT; + const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; + if (!taskRoot || !originalHandlerString) { + failToLoad('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); + } + + const parsedHandler = parseHandlerString(originalHandlerString); + if (!parsedHandler) { + failToLoad(`"${originalHandlerString}" is not a valid handler string.`); + } + + const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); + if (!resolvedFile) { + failToLoad(`Could not find module "${parsedHandler.moduleName}" for handler "${originalHandlerString}".`); + } + + const handlerModule: Record = + resolvedFile.format === 'esm' + ? ((await import(pathToFileURL(resolvedFile.file).href)) as Record) + : (createRequire(import.meta.url)(resolvedFile.file) as Record); + + // Mirrors the AWS runtime's nested handler resolution (`_resolveHandler` in + // https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js), + // e.g. `index.nested.handler`. + const originalHandler = parsedHandler.functionPath + .split('.') + .reduce((nested, key) => (nested as Record | undefined)?.[key], handlerModule); + if (typeof originalHandler !== 'function') { + failToLoad(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); + } + + return originalHandler as (...args: unknown[]) => unknown; +} + +function failToLoad(message: string): never { + throw new Error( + `[Sentry] Failed to load the Lambda handler: ${message} If this error persists, remove the Sentry integration and contact us at https://github.com/getsentry/sentry-javascript/issues`, + ); +} diff --git a/packages/node/src/run-lambda-handler.ts b/packages/node/src/run-lambda-handler.ts index 19f43ae8d2c1..bbc3b0152713 100644 --- a/packages/node/src/run-lambda-handler.ts +++ b/packages/node/src/run-lambda-handler.ts @@ -5,7 +5,7 @@ // It is built as a standalone, ESM-only `build/run-lambda-handler.mjs`, because it uses top-level await to load the // user's handler module, which may itself be ESM. `@sentry/aws-serverless` has its own shim with the full Lambda // instrumentation, and `init` doesn't redirect when that one is in place. -import { loadOriginalLambdaHandler } from './lambda/handlerResolution'; +import { loadOriginalLambdaHandler } from './lambda/loadHandler'; import { wrapLambdaHandlerForTunnel } from './lambda/tunnel'; export const handler = wrapLambdaHandlerForTunnel(await loadOriginalLambdaHandler()); From 3f767b38dea59442f1ca642baa191820f4db57ca Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 16:51:28 -0400 Subject: [PATCH 12/19] test(tanstackstart-react): Drop the managed tunnel 405 test Non-POST requests to the tunnel path now go through the app. --- .../tanstackstart-react/tests/tunnel.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index d42510f36173..b845965f5caf 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -61,15 +61,6 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(errorEvent.transaction).toBe(`${basePath}/`); }); -test('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => { - test.skip(tunnelRouteMode !== 'static', 'Requires a known managed tunnel path'); - - const response = await request.get('/monitor'); - - expect(response.status()).toBe(405); - expect(response.headers()['allow']).toBe('POST'); -}); - function pathnameMatchesTunnelRoute(pathname: string): boolean { return typeof expectedTunnelPathMatcher === 'string' ? pathname === expectedTunnelPathMatcher From aa25934fb502421d961220a6e5bc669c904623c0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 22:26:49 -0400 Subject: [PATCH 13/19] fix(astro): Initialize the server SDK from the server entry A tunnel request from a prerendered page on a cold server reached the app, because the server config only ran from SSR pages and the middleware. Builds now also import it from the server entry, and the server `init` keeps the first client, so a `--import` preload doesn't initialize twice. --- docs/repeated-init.md | 2 +- packages/astro/src/integration/index.ts | 4 +++- packages/astro/src/integration/serverInit.ts | 20 +++++++++++++++++--- packages/astro/src/server/sdk.ts | 12 +++++++++++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/repeated-init.md b/docs/repeated-init.md index a8f6445f0ffc..f584225a858b 100644 --- a/docs/repeated-init.md +++ b/docs/repeated-init.md @@ -37,7 +37,7 @@ When `init()` replaces a client, nothing closes the old one: | Entry point | Repeated call | | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `initAndBind` (browser and its wrappers, Deno), Node `_init`, Vercel Edge | Warns, then replaces the client. Returns the new client. | -| Next.js server, Remix server, Hono Node | Keeps the first client and returns it. Logs in debug mode only. | +| Next.js server, Remix server, Astro server, Hono Node | Keeps the first client and returns it. Logs in debug mode only. | | Hono Bun and Deno | Keeps the first client and returns it. Warns with its own text. | | Nuxt server | Keeps the first client and returns it. Logs that a `--import` preload is no longer needed. | | Cloudflare (default) | Keeps the first client of the isolate and returns it, unless that client is closed or closing. `cacheClient: false` makes a new client on each call, with no warning. | diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index f56e4fa3bd1b..f35e13173481 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -198,7 +198,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) // and also serves as the fallback for non-Cloudflare adapters in production. if (serverInitSnippet) { - updateConfig({ vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet)] } }); + updateConfig({ + vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet, { injectIntoEntry: !isCloudflare })] }, + }); injectScript('page-ssr', SERVER_INIT_IMPORT); } diff --git a/packages/astro/src/integration/serverInit.ts b/packages/astro/src/integration/serverInit.ts index 111ad3c06b42..830ba6c20f46 100644 --- a/packages/astro/src/integration/serverInit.ts +++ b/packages/astro/src/integration/serverInit.ts @@ -14,12 +14,18 @@ export const SERVER_INIT_IMPORT = `import "${SERVER_INIT_MODULE}";\n`; /** * Serves the server init as one module that SSR pages and the Sentry middleware both import, so it runs once, on - * whichever request comes first. With only the `page-ssr` script, a request that renders no SSR page, like a tunnel - * request from a prerendered page on a cold server, would find no client. + * whichever request comes first. With `injectIntoEntry`, a build also imports it from the server entry, so the SDK is + * set up before the server takes its first request: a tunnel request from a prerendered page on a cold server renders + * no SSR page and would otherwise reach the app before the runtime answers tunnel requests. */ -export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin { +export function makeServerInitVitePlugin(serverInitSnippet: string, { injectIntoEntry = false } = {}): VitePlugin { + let isBuild = false; + return { name: 'sentry-astro-server-init', + configResolved: config => { + isBuild = config.command === 'build'; + }, resolveId: id => (id === SERVER_INIT_MODULE || id === SENTRY_MIDDLEWARE_MODULE ? `\0${id}` : null), load: id => { if (id === `\0${SERVER_INIT_MODULE}`) { @@ -30,5 +36,13 @@ export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin ? `${SERVER_INIT_IMPORT}export { onRequest } from "@sentry/astro/middleware";\n` : null; }, + transform(code, id, options) { + // The dev server's module graph has no server entry, and an import added to what it marks as one hangs it. + if (!injectIntoEntry || !isBuild || !options?.ssr || !this.getModuleInfo(id)?.isEntry) { + return null; + } + + return { code: `${SERVER_INIT_IMPORT}${code}`, map: null }; + }, }; } diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 557c1b7812d7..5462ce4a6968 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -1,14 +1,24 @@ import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import { applySdkMetadata } from '@sentry/core'; +import { _INTERNAL_getActiveClient, applySdkMetadata, debug } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; +import { DEBUG_BUILD } from '../debug-build'; /** * * @param options */ export function init(options: NodeOptions): NodeClient | undefined { + // The integration imports the server config from the server entry and from SSR pages, and a `node --import` + // preload of it is supported too. The first init wins. + const existingClient = _INTERNAL_getActiveClient(); + if (existingClient) { + DEBUG_BUILD && debug.log('SDK already initialized'); + + return existingClient; + } + const opts = { ...options, }; From 42534db5366f2a7aa1d370175df5183dc2ee0ec8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 30 Oct 2026 10:13:23 -0400 Subject: [PATCH 14/19] test: Throw malformed and hostile requests at the runtime tunnel Runs the same cases against Node, Cloudflare, Bun and Deno servers, checking the app keeps serving after each, the tunnel answers what it owns, and nothing is reported to the app's project. --- .../cloudflare-integration-tests/runner.ts | 5 + .../suites/tunnel-hardening/index.ts | 32 ++ .../suites/tunnel-hardening/test.ts | 51 +++ .../suites/tunnel-hardening/wrangler.jsonc | 6 + .../suites/tunnel/hardening/instrument.mjs | 9 + .../suites/tunnel/hardening/server.mjs | 24 ++ .../suites/tunnel/hardening/test.ts | 39 ++ dev-packages/test-utils/src/index.ts | 2 + .../test-utils/src/tunnel-hardening.ts | 407 ++++++++++++++++++ .../test/fixtures/tunnelHardeningServer.ts | 68 +++ packages/bun/test/tunnelHardening.test.ts | 107 +++++ packages/deno/test/tunnel-hardening.test.ts | 105 +++++ 12 files changed, 855 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/wrangler.jsonc create mode 100644 dev-packages/node-integration-tests/suites/tunnel/hardening/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tunnel/hardening/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tunnel/hardening/test.ts create mode 100644 dev-packages/test-utils/src/tunnel-hardening.ts create mode 100644 packages/bun/test/fixtures/tunnelHardeningServer.ts create mode 100644 packages/bun/test/tunnelHardening.test.ts create mode 100644 packages/deno/test/tunnel-hardening.test.ts diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 984c79480d68..aa2c0ee73dae 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -149,6 +149,8 @@ type StartResult = { completed(): Promise; /** Every non-ignored envelope received so far, matched or not, for count assertions. */ getReceivedEnvelopes(): Envelope[]; + /** The port wrangler serves the worker on, once it is ready. */ + getPort(): Promise; makeRequest( method: 'get' | 'post', path: string, @@ -558,6 +560,9 @@ export function createRunner(...paths: string[]) { getReceivedEnvelopes: function (): Envelope[] { return receivedEnvelopes; }, + getPort: function (): Promise { + return workerPortPromise; + }, makeRequest: async function ( method: 'get' | 'post', path: string, diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/index.ts b/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/index.ts new file mode 100644 index 000000000000..96d9669509e0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/index.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + ALLOWED_DSNS: string; + TUNNEL_PATH: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.serveTunnelIntegration({ allowedDsns: env.ALLOWED_DSNS.split(',') })], + }), + { + async fetch(request, env) { + const { pathname } = new URL(request.url); + if (pathname === '/health') { + return new Response('ok'); + } + if (pathname === '/done') { + Sentry.captureMessage('done'); + return new Response('done'); + } + // The app's own handler on the tunnel path, which the runtime answers before it. + if (pathname === env.TUNNEL_PATH) { + return new Response('app tunnel handler', { status: 418 }); + } + + return new Response('not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/test.ts b/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/test.ts new file mode 100644 index 000000000000..a7a10f44c776 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/test.ts @@ -0,0 +1,51 @@ +import { getTunnelPath } from '@sentry/core'; +import type { FakeIngests } from '@sentry-internal/test-utils'; +import { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from '@sentry-internal/test-utils'; +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +// `wrangler dev` stalls the next request after any worker answers before reading a large body, Sentry or not. +const WRANGLER_DEV_STALLS = ['70KB header line', 'binary garbage', 'rejected tunnel with a big unread body, then app']; + +let ingests: FakeIngests; + +beforeAll(async () => { + ingests = await startFakeIngests(); +}); + +afterAll(async () => { + await ingests.close(); +}); + +it('keeps the worker serving whatever is thrown at the tunnel, and reports nothing of its own', async ({ signal }) => { + const runner = createRunner(__dirname) + .withWranglerArgs( + '--var', + `ALLOWED_DSNS:${ingests.allDsns().join(',')}`, + '--var', + `TUNNEL_PATH:${getTunnelPath(ingests.dsn('ok'))}`, + ) + // The first event is the app's own, so the tunnel captured no error along the way. + .expect(envelope => { + expect(envelope[1][0]?.[1]).toMatchObject({ message: 'done' }); + }) + .start(signal); + await runner.makeRequest('get', '/health'); + + const results = await runTunnelHardeningCases({ + port: await runner.getPort(), + ingests, + getTunnelPath, + skip: WRANGLER_DEV_STALLS, + }); + + expect(results.filter(result => result.health !== '200')).toEqual([]); + expect(Object.fromEntries(results.map(result => [result.name, result.outcome]))).toMatchObject( + Object.fromEntries( + Object.entries(EXPECTED_TUNNEL_OUTCOMES).filter(([name]) => !WRANGLER_DEV_STALLS.includes(name)), + ), + ); + + await runner.makeRequest('get', '/done'); + await runner.completed(); +}, 180_000); diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel-hardening/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/node-integration-tests/suites/tunnel/hardening/instrument.mjs b/dev-packages/node-integration-tests/suites/tunnel/hardening/instrument.mjs new file mode 100644 index 000000000000..3801c329cbb3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/hardening/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + integrations: [Sentry.serveTunnelIntegration({ allowedDsns: process.env.ALLOWED_DSNS.split(',') })], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/hardening/server.mjs b/dev-packages/node-integration-tests/suites/tunnel/hardening/server.mjs new file mode 100644 index 000000000000..0535cf529f2c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/hardening/server.mjs @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +// Consumes the body of every request, which the tunnel must not be affected by. +app.use(express.json()); + +// The app's own handler on the tunnel path, which the runtime answers before it. +app.post(process.env.TUNNEL_PATH, (_req, res) => { + res.status(418).send('app tunnel handler'); +}); + +app.get('/health', (_req, res) => { + res.send('ok'); +}); + +app.get('/done', (_req, res) => { + Sentry.captureMessage('done'); + res.send('done'); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tunnel/hardening/test.ts b/dev-packages/node-integration-tests/suites/tunnel/hardening/test.ts new file mode 100644 index 000000000000..dac7b16d0277 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/hardening/test.ts @@ -0,0 +1,39 @@ +import { getTunnelPath } from '@sentry/core'; +import type { FakeIngests } from '@sentry-internal/test-utils'; +import { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from '@sentry-internal/test-utils'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('managed tunnel hardening', () => { + let ingests: FakeIngests; + + beforeAll(async () => { + ingests = await startFakeIngests(); + }); + + afterAll(async () => { + cleanupChildProcesses(); + await ingests.close(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('keeps the app serving whatever is thrown at the tunnel, and reports nothing of its own', async () => { + const runner = createRunner() + .withEnv({ ALLOWED_DSNS: ingests.allDsns().join(','), TUNNEL_PATH: getTunnelPath(ingests.dsn('ok'))! }) + // The first event is the app's own, so the tunnel captured no error along the way. + .expect({ event: { message: 'done' } }) + .start(); + await runner.makeRequest('get', '/health'); + + const results = await runTunnelHardeningCases({ port: runner.getPort()!, ingests, getTunnelPath }); + + expect(results.filter(result => result.health !== '200')).toEqual([]); + expect(Object.fromEntries(results.map(result => [result.name, result.outcome]))).toMatchObject( + EXPECTED_TUNNEL_OUTCOMES, + ); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }, 120_000); + }); +}); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 3b82c4a81515..ff74981a603d 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -18,6 +18,8 @@ export { } from './event-proxy-server'; export type { SerializedStreamedSpan } from '@sentry/core'; export { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from './tunnel'; +export { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from './tunnel-hardening'; +export type { FakeIngests, TunnelHardeningResult } from './tunnel-hardening'; export { findAbsolutePathImports, diff --git a/dev-packages/test-utils/src/tunnel-hardening.ts b/dev-packages/test-utils/src/tunnel-hardening.ts new file mode 100644 index 000000000000..76b2145d6eeb --- /dev/null +++ b/dev-packages/test-utils/src/tunnel-hardening.ts @@ -0,0 +1,407 @@ +// Throws every request we could think of at a server whose runtime answers managed tunnel requests, and checks the +// app keeps serving after each one. The tunnel must never break the app, whatever the client, the envelope or the +// ingest does. Only Node built-ins, so Bun and Deno tests can import this file directly. +import { Buffer } from 'node:buffer'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import * as zlib from 'node:zlib'; + +type IngestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void; + +/** + * Fake ingests, one per behavior. Their DSNs are allowed tunnel DSNs, so the tunnel forwards to them. + */ +const INGEST_HANDLERS: Record = { + ok: (req, res) => drain(req, () => res.end('{}')), + hang: () => undefined, + reset: req => req.socket.destroy(), + error500: (req, res) => drain(req, () => res.writeHead(500).end('boom')), + drip: (_req, res) => { + res.writeHead(200); + let n = 0; + const timer = setInterval(() => (n++ < 20 ? res.write('.') : (clearInterval(timer), res.end())), 20); + res.on('close', () => clearInterval(timer)); + }, + huge: (_req, res) => res.end(Buffer.alloc(5e6, 'x')), + earlyReply: (_req, res) => res.writeHead(200).end('{}'), + resetMidResponse: (req, res) => { + res.writeHead(200); + res.write('partial'); + setTimeout(() => req.socket.destroy(), 20); + }, + // Nothing listens on its port. + refused: null, +}; + +export interface FakeIngests { + /** The DSN of the fake ingest with this behavior. */ + dsn(name: keyof typeof INGEST_HANDLERS): string; + /** Every fake ingest DSN, for `allowedDsns`. */ + allDsns(): string[]; + close(): Promise; +} + +/** + * Starts the fake ingests on free ports. + */ +export async function startFakeIngests(): Promise { + const servers: http.Server[] = []; + const sockets = new Set(); + const ports: Record = {}; + + for (const [name, handler] of Object.entries(INGEST_HANDLERS)) { + const server = http.createServer(handler ?? (() => undefined)); + server.on('connection', socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + ports[name] = (server.address() as net.AddressInfo).port; + if (handler) { + servers.push(server); + } else { + // Keep the port, refuse connections. + await new Promise(resolve => server.close(() => resolve())); + } + } + + const dsn = (name: string): string => `http://${name}@127.0.0.1:${ports[name]}/1`; + + return { + dsn, + allDsns: () => Object.keys(INGEST_HANDLERS).map(dsn), + close: async () => { + sockets.forEach(socket => socket.destroy()); + await Promise.all(servers.map(server => new Promise(resolve => server.close(resolve)))); + }, + }; +} + +export interface TunnelHardeningOptions { + /** The port of the app under test. */ + port: number; + ingests: FakeIngests; + /** `getTunnelPath` from `@sentry/core`, passed in so this file has no dependencies. */ + getTunnelPath: (dsn: string) => string | undefined; + /** The app's path prefix, if it serves the tunnel under one. */ + basePath?: string; + /** Skips cases by name, for runtimes whose HTTP stack rejects them before any JavaScript runs. */ + skip?: string[]; +} + +export interface TunnelHardeningResult { + name: string; + /** The response status(es) the client got, or what happened instead. */ + outcome: string; + /** The status of a request to the app's `GET /health` right after the case. */ + health: string; +} + +/** + * Runs every case against the app and returns what happened. The app must answer `GET /health` with a 200. + */ +export async function runTunnelHardeningCases(options: TunnelHardeningOptions): Promise { + const results: TunnelHardeningResult[] = []; + for (const [name, run] of getCases(options)) { + if (options.skip?.includes(name)) { + continue; + } + + const outcome = await run(); + results.push({ name, outcome, health: await request(options.port, 'GET', '/health') }); + } + + return results; +} + +/** + * The outcomes the tunnel itself decides, as opposed to the runtime's HTTP parser, so every runtime should agree. + */ +export const EXPECTED_TUNNEL_OUTCOMES: Record = { + 'valid envelope': '200', + 'trailing slash': '200', + 'query string': '200', + // The browser SDK always sends `Content-Length`, and the forward streams up to it. + chunked: '411', + 'invalid JSON header': '400', + 'dsn is an object': '400', + '__proto__ header': '400', + // A browser compresses item payloads, never the whole envelope. + 'gzip encoded': '400', + 'dsn not allowed': '403', + 'other allowed dsn on this path': '500', + '2MB body': '200', + 'slow body': '200', + 'pipelined tunnel then app': '200,200', + 'rejected tunnel with a big unread body, then app': '403,200', + 'pipelined app, tunnel, app': '200,200,200', + 'ingest error500': '500', + 'ingest refused': '500', + 'ingest earlyReply': '200', +}; + +type Case = [name: string, run: () => Promise]; + +function getCases({ port, ingests, getTunnelPath, basePath = '' }: TunnelHardeningOptions): Case[] { + const tunnelPath = (name: string): string => `${basePath}${getTunnelPath(ingests.dsn(name))}`; + const envelope = (name: string, body = '{"type":"event"}\n{"message":"hardening"}\n'): string => + `{"dsn":"${ingests.dsn(name)}"}\n${body}`; + const raw = (chunks: Chunk | Chunk[], rawOptions?: RawOptions): Promise => + rawRequest(port, chunks, rawOptions); + const P = tunnelPath('ok'); + const OK = envelope('ok'); + + return [ + // Matching + ['valid envelope', () => raw(post(P, OK))], + ['GET tunnel path', () => raw(get(P))], + ['HEAD tunnel path', () => raw(`HEAD ${P} HTTP/1.1\r\nHost: localhost\r\n\r\n`)], + ['PUT tunnel path', () => raw(post(P, OK).replace('POST', 'PUT'))], + ['OPTIONS tunnel path', () => raw(`OPTIONS ${P} HTTP/1.1\r\nHost: localhost\r\n\r\n`)], + ['OPTIONS *', () => raw('OPTIONS * HTTP/1.1\r\nHost: localhost\r\n\r\n')], + ['trailing slash', () => raw(post(`${P}/`, OK))], + ['query string', () => raw(post(`${P}?a=1&b`, OK))], + ['double slash', () => raw(post(`/${P}`, OK))], + ['uppercase path', () => raw(post(P.toUpperCase(), OK))], + [ + 'percent-encoded path', + () => + raw( + post( + P.replace(/[^/]/g, c => `%${c.charCodeAt(0).toString(16)}`), + OK, + ), + ), + ], + ['dot segments', () => raw(post(`/x/..${P}`, OK))], + ['absolute-form URL', () => raw(post(`http://evil.example${P}`, OK))], + ['bad Host header', () => raw(post(P, OK).replace('Host: localhost', 'Host: [::bad'))], + ['no Host header', () => raw(post(P, OK).replace('Host: localhost\r\n', ''))], + ['HTTP/1.0', () => raw(post(P, OK).replace('HTTP/1.1', 'HTTP/1.0'))], + ['null byte in path', () => raw(post(`${P}%00`, OK))], + ['8KB query', () => raw(post(`${P}?${'a'.repeat(8000)}`, OK))], + // Bodies + ['empty body', () => raw(post(P, ''))], + ['no newline', () => raw(post(P, `{"dsn":"${ingests.dsn('ok')}"}`))], + ['invalid JSON header', () => raw(post(P, '{"dsn":\n{}\n'))], + ['dsn is a number', () => raw(post(P, '{"dsn":42}\n{}\n'))], + ['dsn is an array', () => raw(post(P, '{"dsn":["a"]}\n{}\n'))], + ['dsn is an object', () => raw(post(P, '{"dsn":{"toString":1}}\n{}\n'))], + ['dsn is null', () => raw(post(P, '{"dsn":null}\n{}\n'))], + ['header is an array', () => raw(post(P, '[1,2]\n{}\n'))], + ['header is a string', () => raw(post(P, '"hi"\n{}\n'))], + ['__proto__ header', () => raw(post(P, `{"__proto__":{"dsn":"${ingests.dsn('ok')}"}}\n{}\n`))], + ['dsn not allowed', () => raw(post(P, '{"dsn":"https://k@o1.ingest.sentry.io/1"}\n{}\n'))], + ['dsn garbage string', () => raw(post(P, '{"dsn":"::::"}\n{}\n'))], + ['other allowed dsn on this path', () => raw(post(P, envelope('error500')))], + ['70KB header line', () => raw(post(P, `{"dsn":"${ingests.dsn('ok')}","pad":"${'x'.repeat(70_000)}"}\n{}\n`))], + [ + 'binary garbage', + () => raw(post(P, Buffer.from(Array.from({ length: 5000 }, (_, i) => (i * 37) % 256)).toString('latin1'))), + ], + ['invalid UTF-8', () => raw(post(P, '\xff\xfe\xfd\n\xc3\x28\n'))], + [ + 'gzip encoded', + () => { + const gzipped = zlib.gzipSync(OK); + + return raw([ + `POST ${P} HTTP/1.1\r\nHost: localhost\r\nContent-Length: ${gzipped.length}\r\nContent-Encoding: gzip\r\n\r\n`, + gzipped, + ]); + }, + ], + ['gzip header, plain body', () => raw(post(P, OK, 'Content-Encoding: gzip\r\n'))], + [ + '2MB body', + () => raw(post(P, envelope('ok', `{"type":"attachment"}\n${'a'.repeat(2e6)}\n`)), { timeoutMs: 10_000 }), + ], + ['chunked', () => raw(chunked(P, OK))], + ['chunked + trailers', () => raw(chunked(P, OK, 'Trailer: X-T\r\n', 'X-T: 1\r\n'))], + ['bad chunk size', () => raw(`POST ${P} HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n`)], + [ + 'Content-Length and Transfer-Encoding', + () => + raw( + `POST ${P} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n`, + ), + ], + [ + 'duplicate Content-Length', + () => raw(`POST ${P} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 3\r\nContent-Length: 4\r\n\r\nabcd`), + ], + ['Expect: 100-continue', () => raw(post(P, OK, 'Expect: 100-continue\r\n'), { statuses: 2 })], + [ + 'Upgrade: websocket', + () => + raw( + get( + P, + 'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n', + ), + ), + ], + // Client misbehavior + [ + 'abort before the envelope header ends', + () => raw(withContentLength(post(P, OK).slice(0, -OK.length + 10), 9999), { closeAfterMs: 100 }), + ], + ['abort mid-body', () => raw(withContentLength(post(P, OK), 99_999), { closeAfterMs: 100 })], + ['Content-Length too large, then wait', () => raw(withContentLength(post(P, OK), 99_999), { timeoutMs: 1500 })], + ['abort while forwarding', () => raw(post(tunnelPath('hang'), envelope('hang')), { closeAfterMs: 300 })], + [ + 'slow body', + () => + raw( + [ + withContentLength(post(P, ''), Buffer.byteLength(OK)), + ...[...OK.match(/.{1,8}/gs)!].flatMap(chunk => [chunk, 20]), + ], + { timeoutMs: 10_000 }, + ), + ], + ['half-close after body', () => raw(post(P, OK), { endAfterWrite: true })], + // Keep-alive: the tunnel must leave the connection usable for the app. + ['pipelined tunnel then app', () => raw(post(P, OK) + get('/health'), { statuses: 2 })], + [ + 'rejected tunnel with a big unread body, then app', + () => + raw(post(P, `{"dsn":"https://k@o1.ingest.sentry.io/1"}\n${'x'.repeat(200_000)}`) + get('/health'), { + statuses: 2, + }), + ], + ['pipelined app, tunnel, app', () => raw(get('/health') + post(P, OK) + get('/health'), { statuses: 3 })], + // Ingest misbehavior + ...(['hang', 'reset', 'error500', 'drip', 'huge', 'earlyReply', 'resetMidResponse', 'refused'] as const).map( + (name): Case => [ + `ingest ${name}`, + () => raw(post(tunnelPath(name), envelope(name)), { timeoutMs: name === 'hang' ? 1500 : 8000 }), + ], + ), + // Load + [ + '100 mixed concurrent requests', + async () => { + const jobs = Array.from({ length: 100 }, (_, i) => + [ + () => request(port, 'POST', P, OK), + () => request(port, 'GET', '/health'), + () => raw(post(tunnelPath('reset'), envelope('reset'))), + () => raw(post(tunnelPath('hang'), envelope('hang')), { closeAfterMs: 200 }), + ][i % 4]!(), + ); + const counts: Record = {}; + for (const outcome of await Promise.all(jobs)) { + counts[outcome] = (counts[outcome] ?? 0) + 1; + } + + return Object.entries(counts) + .map(([outcome, count]) => `${outcome}x${count}`) + .join(' '); + }, + ], + ]; +} + +type Chunk = string | Buffer | number; + +interface RawOptions { + /** Destroys the socket after this many ms. */ + closeAfterMs?: number; + timeoutMs?: number; + /** How many response status lines to wait for. */ + statuses?: number; + /** Half-closes the socket after writing. */ + endAfterWrite?: boolean; +} + +/** + * Writes raw bytes to the app (numbers are pauses in ms) and returns the response status(es), or what happened + * instead: `aborted by client`, `timeout`, `closed`, or a socket error code. + */ +function rawRequest( + port: number, + chunks: Chunk | Chunk[], + { closeAfterMs, timeoutMs = 3000, statuses = 1, endAfterWrite = false }: RawOptions = {}, +): Promise { + return new Promise(resolve => { + const socket = net.connect(port, '127.0.0.1'); + let data = ''; + let settled = false; + const statusesSoFar = (): string[] => [...data.matchAll(/HTTP\/1\.[01] (\d{3})/g)].map(match => match[1]!); + const finish = (result: string): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + socket.destroy(); + resolve(result); + }; + const timeout = setTimeout(() => finish(statusesSoFar().join(',') || 'timeout'), timeoutMs); + + socket.on('data', chunk => { + data += chunk.toString('latin1'); + if (statusesSoFar().length >= statuses) { + finish(statusesSoFar().join(',')); + } + }); + socket.on('error', error => finish(`socket ${(error as Error & { code?: string }).code}`)); + socket.on('close', () => finish(statusesSoFar().join(',') || (data ? 'garbled' : 'closed'))); + socket.on('connect', async () => { + for (const chunk of ([] as Chunk[]).concat(chunks)) { + if (typeof chunk === 'number') { + await new Promise(r => setTimeout(r, chunk)); + } else if (!socket.destroyed) { + socket.write(chunk); + } + } + if (endAfterWrite && !socket.destroyed) { + socket.end(); + } + if (closeAfterMs !== undefined) { + setTimeout(() => finish('aborted by client'), closeAfterMs); + } + }); + }); +} + +/** + * Makes a request with `fetch` and returns the status, or the error code. + */ +async function request(port: number, method: string, path: string, body?: string): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + body, + signal: AbortSignal.timeout(5000), + }); + await response.arrayBuffer(); + + return String(response.status); + } catch (error) { + return `fetch ${(error as { cause?: { code?: string } }).cause?.code ?? (error as Error).name}`; + } +} + +function post(path: string, body: string, headers = ''): string { + return `POST ${path} HTTP/1.1\r\nHost: localhost\r\nContent-Length: ${Buffer.byteLength(body)}\r\n${headers}\r\n${body}`; +} + +function get(path: string, headers = ''): string { + return `GET ${path} HTTP/1.1\r\nHost: localhost\r\n${headers}\r\n`; +} + +function chunked(path: string, body: string, headers = '', trailers = ''): string { + return `POST ${path} HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n${headers}\r\n${Buffer.byteLength(body).toString(16)}\r\n${body}\r\n0\r\n${trailers}\r\n`; +} + +function withContentLength(rawRequest: string, length: number): string { + return rawRequest.replace(/Content-Length: \d+/, `Content-Length: ${length}`); +} + +function drain(req: http.IncomingMessage, done: () => void): void { + req.on('data', () => undefined); + req.on('end', done); + req.on('error', () => undefined); +} diff --git a/packages/bun/test/fixtures/tunnelHardeningServer.ts b/packages/bun/test/fixtures/tunnelHardeningServer.ts new file mode 100644 index 000000000000..8a46d9645dd3 --- /dev/null +++ b/packages/bun/test/fixtures/tunnelHardeningServer.ts @@ -0,0 +1,68 @@ +// The app `tunnelHardening.test.ts` runs in its own process: a client in the server's process stops getting timers +// while a tunnel route waits on a hung ingest. +import * as SentryCore from '@sentry/core'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { init, serveTunnelIntegration } from '../../src'; + +const tunnelPath = process.env.TUNNEL_PATH!; + +init({ + dsn: 'https://username@domain/123', + integrations: [serveTunnelIntegration({ allowedDsns: process.env.ALLOWED_DSNS!.split(',') })], + beforeSend: event => { + // The test fails on any of these. + process.stdout.write(`EVENT ${JSON.stringify(event.exception?.values?.[0] ?? event.message)}\n`); + + return null; + }, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), +}); + +/** + * Answers `/health`, and the tunnel path like an app that handles it itself, which the runtime answers before it. + */ +function appStatus(pathname: string): number { + if (pathname === '/health') { + return 200; + } + + return pathname === tunnelPath ? 418 : 404; +} + +/** + * Prints the port for the test to read. + */ +function announce(port: number | undefined): void { + process.stdout.write(`PORT ${port}\n`); +} + +switch (process.env.SERVER) { + case 'fetch': + announce( + Bun.serve({ + port: 0, + fetch: request => new Response('app', { status: appStatus(new URL(request.url).pathname) }), + }).port, + ); + break; + case 'routes': + announce( + Bun.serve({ + port: 0, + routes: { + '/health': new Response('ok'), + // A static catch-all answers without running any JavaScript, like an SPA fallback. + '/*': new Response('app catch-all', { status: 418 }), + }, + }).port, + ); + break; + default: { + const server = http.createServer((request, response) => { + response.writeHead(appStatus(request.url ?? '/')).end('app'); + }); + server.listen(0, '127.0.0.1', () => announce((server.address() as AddressInfo).port)); + } +} diff --git a/packages/bun/test/tunnelHardening.test.ts b/packages/bun/test/tunnelHardening.test.ts new file mode 100644 index 000000000000..95ca8ef82fa5 --- /dev/null +++ b/packages/bun/test/tunnelHardening.test.ts @@ -0,0 +1,107 @@ +import * as SentryCore from '@sentry/core'; +import type { Subprocess } from 'bun'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as path from 'node:path'; +import type { FakeIngests } from '../../../dev-packages/test-utils/src/tunnel-hardening'; +import { + EXPECTED_TUNNEL_OUTCOMES, + runTunnelHardeningCases, + startFakeIngests, +} from '../../../dev-packages/test-utils/src/tunnel-hardening'; + +// Bun answers nothing on a connection whose first request awaits anything while a second one is queued behind it, +// Sentry or not. +const PIPELINED_CASES = [ + 'pipelined tunnel then app', + 'rejected tunnel with a big unread body, then app', + 'pipelined app, tunnel, app', +]; + +describe('managed tunnel hardening', () => { + let ingests: FakeIngests; + + beforeAll(async () => { + ingests = await startFakeIngests(); + }); + + afterAll(async () => { + await ingests.close(); + }); + + test.each([ + ['Bun.serve with a fetch handler', 'fetch'], + ['Bun.serve with routes and a catch-all', 'routes'], + ['node:http', 'node-http'], + ])( + '%s', + async (_name, server) => { + const app = Bun.spawn([process.execPath, path.join(__dirname, 'fixtures/tunnelHardeningServer.ts')], { + env: { + ...process.env, + SERVER: server, + ALLOWED_DSNS: ingests.allDsns().join(','), + TUNNEL_PATH: SentryCore.getTunnelPath(ingests.dsn('ok')), + }, + stdout: 'pipe', + stderr: 'inherit', + }); + + try { + const output = readOutput(app); + const port = Number((await output.waitFor(/PORT (\d+)/))[1]); + + const results = await runTunnelHardeningCases({ + port, + ingests, + getTunnelPath: SentryCore.getTunnelPath, + skip: PIPELINED_CASES, + }); + + expect(results.filter(result => result.health !== '200')).toEqual([]); + expect(Object.fromEntries(results.map(result => [result.name, result.outcome]))).toMatchObject( + Object.fromEntries( + Object.entries(EXPECTED_TUNNEL_OUTCOMES).filter(([name]) => !PIPELINED_CASES.includes(name)), + ), + ); + // The tunnel reports nothing of its own to the app's project. + expect(output.text().match(/^EVENT .*/gm)).toBeNull(); + } finally { + app.kill(); + } + }, + 120_000, + ); +}); + +/** + * Collects the app's stdout. + */ +function readOutput(app: Subprocess<'ignore', 'pipe', 'inherit'>): { + text: () => string; + waitFor: (pattern: RegExp) => Promise; +} { + let text = ''; + const listeners = new Set<() => void>(); + void (async () => { + for await (const chunk of app.stdout) { + text += new TextDecoder().decode(chunk); + listeners.forEach(listener => listener()); + } + })(); + + return { + text: () => text, + waitFor: pattern => + new Promise(resolve => { + const check = (): void => { + const match = text.match(pattern); + if (match) { + listeners.delete(check); + resolve(match); + } + }; + listeners.add(check); + check(); + }), + }; +} diff --git a/packages/deno/test/tunnel-hardening.test.ts b/packages/deno/test/tunnel-hardening.test.ts new file mode 100644 index 000000000000..7da6cbb9eebd --- /dev/null +++ b/packages/deno/test/tunnel-hardening.test.ts @@ -0,0 +1,105 @@ +// + +import type { ErrorEvent } from '@sentry/core'; +import { getMainCarrier, getTunnelPath } from '@sentry/core'; +import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { + EXPECTED_TUNNEL_OUTCOMES, + runTunnelHardeningCases, + startFakeIngests, +} from '../../../dev-packages/test-utils/src/tunnel-hardening.ts'; +import { init, serveTunnelIntegration } from '../build/esm/index.js'; + +/** + * Inits the SDK with a tunnel to every fake ingest, and returns the error events it reports. + */ +function initSdk(allowedDsns: string[]): ErrorEvent[] { + getMainCarrier().__SENTRY__ = undefined; + const events: ErrorEvent[] = []; + init({ + dsn: 'https://username@domain/123', + integrations: [serveTunnelIntegration({ allowedDsns })], + beforeSend: event => { + events.push(event); + + return null; + }, + }); + + return events; +} + +/** + * Answers `/health`, and the tunnel path like an app that handles it itself, which the runtime answers before it. + */ +function appStatus(pathname: string, tunnelPath: string): number { + if (pathname === '/health') { + return 200; + } + + return pathname === tunnelPath ? 418 : 404; +} + +async function expectAppUnharmed( + port: number, + ingests: Awaited>, + events: ErrorEvent[], + skip: string[] = [], +): Promise { + const results = await runTunnelHardeningCases({ port, ingests, getTunnelPath: dsn => getTunnelPath(dsn), skip }); + + assertEquals( + results.filter(result => result.health !== '200'), + [], + ); + assertObjectMatch( + Object.fromEntries(results.map(result => [result.name, result.outcome])), + Object.fromEntries(Object.entries(EXPECTED_TUNNEL_OUTCOMES).filter(([name]) => !skip.includes(name))), + ); + // The tunnel reports nothing of its own to the app's project. + assertEquals(events, []); +} + +Deno.test('managed tunnel hardening: Deno.serve', async () => { + const ingests = await startFakeIngests(); + const tunnelPath = getTunnelPath(ingests.dsn('ok'))!; + const events = initSdk(ingests.allDsns()); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, request => { + return new Response('app', { status: appStatus(new URL(request.url).pathname, tunnelPath) }); + }); + await listening; + + try { + // `Deno.serve` resets a connection it answered before reading a large body, Sentry or not. + await expectAppUnharmed(server.addr.port, ingests, events, ['rejected tunnel with a big unread body, then app']); + } finally { + abortController.abort(); + await server.finished; + await ingests.close(); + } +}); + +Deno.test('managed tunnel hardening: node:http', async () => { + const ingests = await startFakeIngests(); + const tunnelPath = getTunnelPath(ingests.dsn('ok'))!; + const events = initSdk(ingests.allDsns()); + + const server = http.createServer((request, response) => { + response.writeHead(appStatus(request.url ?? '/', tunnelPath)).end('app'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + + try { + await expectAppUnharmed((server.address() as AddressInfo).port, ingests, events); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + await ingests.close(); + } +}); From 5772e1a6706c2d9bad88d427213b3884f8676fa4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 30 Oct 2026 10:23:20 -0400 Subject: [PATCH 15/19] test(bun): Skip the tunnel hardening node suite on Bun `@sentry/node` doesn't answer the tunnel on Bun, and Bun drops pipelined requests behind an async one. `packages/bun` runs the same cases against Bun's servers. --- dev-packages/bun-integration-tests/node-suites/excludes.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 116d86d2634c..3881f7b52ced 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -26,6 +26,7 @@ const NO_HTTP_SERVER_SPANS = [ 'suites/tracing/httpServerSpans-unrouted/test.ts', 'suites/tracing/ignoreSpans/**', 'suites/tracing/managed-tunnel/test.ts', + 'suites/tunnel/hardening/test.ts', 'suites/tracing/meta-tags-twp-errors/test.ts', 'suites/tracing/meta-tags/test.ts', 'suites/tracing/requestData/test.ts', @@ -193,7 +194,13 @@ export const NODE_SUITES_EXCLUDE = ['**/node_modules/**', ...NODE_ONLY, ...NO_AU export const SENTRY_NODE_EXCLUDE = [...NO_HTTP_SERVER_SPANS, ...NO_FETCH_INSTRUMENTATION]; // Excluded only in the `node-suites-sentry-bun` project, which maps `@sentry/node` to `@sentry/bun`. +// Bun answers nothing on a connection whose first request awaits anything while a second one is +// queued behind it, which the pipelined cases need. `packages/bun` runs the same cases against Bun's +// servers without those. +const BUN_DROPS_PIPELINED_REQUESTS = ['suites/tunnel/hardening/test.ts']; + export const SENTRY_BUN_EXCLUDE = [ + ...BUN_DROPS_PIPELINED_REQUESTS, ...HTTP_SERVER_OPTIONS_IGNORED, ...NO_EXPRESS_INSTRUMENTATION, ...NO_NATIVE_NODE_FETCH_INTEGRATION, From 82b3ff4b24442919d4082112e02b49073e3cd4c4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 28 Oct 2026 13:56:42 -0400 Subject: [PATCH 16/19] ref(nextjs): Forward tunnel route requests with core's `handleTunnelRequest` The tunnel route handler now leaves DSN normalization, the allowlist and tracing suppression to core. As in every other SDK, `allowedDsns` adds to the server's own DSN, and the default `POST` accepts the DSNs `serveTunnelIntegration` allows. --- packages/nextjs/src/config/types.ts | 4 +- packages/nextjs/src/tunnel/index.ts | 40 +++-------------- packages/nextjs/test/tunnel/index.test.ts | 52 +++++++++++++---------- 3 files changed, 39 insertions(+), 57 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 4e1e6bc2d9e3..c690b2665450 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -280,8 +280,8 @@ export type SentryBuildOptions = Omit< * export { POST } from '@sentry/nextjs/tunnel'; * ``` * - * This handler accepts the DSN of the server SDK, also a self-hosted one. For other DSNs, use - * `createSentryTunnelRoute` from the same import. + * This handler accepts the DSN of the server SDK, also a self-hosted one, and the DSNs `serveTunnelIntegration` + * allows. For other DSNs, use `createSentryTunnelRoute` from the same import. * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index d86dd2d0a99d..c55e9cfbfa5c 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -1,9 +1,9 @@ -import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core'; +import { handleTunnelRequest } from '@sentry/core'; export interface CreateSentryTunnelRouteOptions { /** - * The DSNs that the route accepts. It rejects envelopes for other DSNs with a 403. - * Defaults to the DSN of the server SDK. + * DSNs whose envelopes the route forwards, in addition to the DSN of the server SDK. It rejects envelopes for other + * DSNs with a 403. Defaults to the DSNs `serveTunnelIntegration` allows. */ allowedDsns?: string[]; } @@ -27,24 +27,10 @@ type TunnelRouteHandler = (request: Request) => Promise; * ``` */ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } { - const allowedDsnsFromOptions = options.allowedDsns?.length ? normalizeDsns(options.allowedDsns) : undefined; + const allowedDsns = options.allowedDsns?.length ? options.allowedDsns.map(dsn => dsn.trim()) : undefined; return { - POST: async request => { - // Read on each request, because the client can be set up after this module loads. - const allowedDsns = allowedDsnsFromOptions ?? getServerDsns(); - if (!allowedDsns.length) { - return new Response( - allowedDsnsFromOptions - ? 'Tunnel route has no valid DSN in allowedDsns.' - : 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', - { status: 500 }, - ); - } - - // Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers. - return suppressTracing(() => handleTunnelRequest(request, { allowedDsns })); - }, + POST: request => handleTunnelRequest(request, { allowedDsns }), }; } @@ -52,7 +38,8 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions * Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the * request headers (such as cookies). * - * It accepts envelopes only for the DSN of the server SDK. For other DSNs, use `createSentryTunnelRoute`. + * It accepts envelopes for the DSN of the server SDK, and the DSNs `serveTunnelIntegration` allows. For other DSNs, + * use `createSentryTunnelRoute`. * * @example * ```ts @@ -61,16 +48,3 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions * ``` */ export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; - -function getServerDsns(): string[] { - const dsn = getClient()?.getDsn(); - return dsn ? [dsnToString(dsn)] : []; -} - -// The same form as the DSN in the envelope header, because `handleTunnelRequest` compares exact strings. -function normalizeDsns(dsns: string[]): string[] { - return dsns.flatMap(dsn => { - const dsnComponents = makeDsn(dsn.trim()); - return dsnComponents ? [dsnToString(dsnComponents)] : []; - }); -} diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 516543a2b5a9..a6fd021e3417 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -1,15 +1,9 @@ -import type * as SentryCore from '@sentry/core'; -import { createEnvelope, makeDsn, serializeEnvelope } from '@sentry/core'; +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, makeDsn, serializeEnvelope } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { serveTunnelIntegration } from '@sentry/core/server'; import { createSentryTunnelRoute, POST } from '../../src/tunnel'; -const { mockGetClient } = vi.hoisted(() => ({ mockGetClient: vi.fn() })); - -vi.mock('@sentry/core', async importOriginal => ({ - ...(await importOriginal()), - getClient: mockGetClient, -})); - const SERVER_DSN = 'https://public@o1.ingest.sentry.io/1'; const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/2'; @@ -33,7 +27,8 @@ function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Arra } function useClientDsn(dsn: string | undefined): void { - mockGetClient.mockReturnValue(dsn ? { getDsn: () => makeDsn(dsn) } : undefined); + const client = dsn ? ({ getDsn: () => makeDsn(dsn), getOptions: () => ({}) } as unknown as Client) : undefined; + getCurrentScope().setClient(client); } let fetchSpy: ReturnType; @@ -75,9 +70,6 @@ describe('POST', () => { const response = await POST(request); expect(response.status).toBe(500); - await expect(response.text()).resolves.toBe( - 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', - ); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -91,6 +83,22 @@ describe('POST', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('accepts the DSNs `serveTunnelIntegration` allows', async () => { + const integration = serveTunnelIntegration({ allowedDsns: [BROWSER_DSN] }); + const options = {}; + const client = { + getDsn: () => makeDsn(SERVER_DSN), + getOptions: () => options, + getIntegrationByName: () => integration, + } as unknown as Client; + integration.setup?.(client); + getCurrentScope().setClient(client); + + const response = await POST(createEnvelopeRequest(BROWSER_DSN).request); + + expect(response.status).toBe(200); + }); + it('does not accept DSNs from environment variables', async () => { useClientDsn(SERVER_DSN); vi.stubEnv('SENTRY_DSN', BROWSER_DSN); @@ -104,16 +112,18 @@ describe('POST', () => { }); describe('createSentryTunnelRoute', () => { - it('accepts only the DSNs in `allowedDsns`', async () => { + it('accepts the DSNs in `allowedDsns` and the server DSN', async () => { useClientDsn(SERVER_DSN); const { POST: handler } = createSentryTunnelRoute({ allowedDsns: [BROWSER_DSN] }); - const allowedResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); - const rejectedResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + const browserResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); + const serverResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + const otherResponse = await handler(createEnvelopeRequest('https://other@o2.ingest.sentry.io/3').request); - expect(allowedResponse.status).toBe(200); - expect(rejectedResponse.status).toBe(403); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(browserResponse.status).toBe(200); + expect(serverResponse.status).toBe(200); + expect(otherResponse.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(2); }); it.each([ @@ -138,15 +148,13 @@ describe('createSentryTunnelRoute', () => { expect(response.status).toBe(200); }); - it('responds with 500 when no allowed DSN is valid', async () => { + it('responds with 500 when no allowed DSN is valid and the server SDK has no DSN', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); - useClientDsn(SERVER_DSN); const { POST: handler } = createSentryTunnelRoute({ allowedDsns: ['not-a-dsn'] }); const response = await handler(createEnvelopeRequest(SERVER_DSN).request); expect(response.status).toBe(500); - await expect(response.text()).resolves.toBe('Tunnel route has no valid DSN in allowedDsns.'); expect(fetchSpy).not.toHaveBeenCalled(); }); }); From c501ea869ff1166bb2e8c38612dded46b697e615 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 10:07:50 -0400 Subject: [PATCH 17/19] feat(nextjs): Serve the browser's managed tunnel under the app's `basePath` `tunnel: true` in the browser now sends to `/`, like the SvelteKit and React Router SDKs do with their base paths. A `basePath` the user set and a `tunnel` URL stay as written. --- packages/nextjs/src/client/index.ts | 19 +++++++++++++-- packages/nextjs/test/clientSdk.test.ts | 32 +++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 9f1ed4e4f768..2327a5ab4955 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -2,7 +2,13 @@ // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ import type { Client, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; +import { + _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, + applySdkMetadata, + consoleSandbox, + getGlobalScope, + GLOBAL_OBJ, +} from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -55,6 +61,8 @@ export function init(options: BrowserOptions): Client | undefined { } satisfies BrowserOptions; applyTunnelRouteOption(opts); + // The managed tunnel is served under the app's `basePath`, like every other route. A `basePath` the user set wins. + opts.tunnel = applyTunnelDefaults(opts.tunnel, { basePath: getInjectedBasePath() }); applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); opts.ignoreSpans = [ @@ -89,6 +97,13 @@ export function init(options: BrowserOptions): Client | undefined { return client; } +/** + * The app's `basePath`, which `withSentryConfig` injects. + */ +function getInjectedBasePath(): string | undefined { + return process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; +} + function getDefaultIntegrations(options: BrowserOptions): Integration[] { const customDefaultIntegrations = getReactDefaultIntegrations(options); // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", @@ -104,7 +119,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] { globalWithInjectedValues._sentryRewriteFramesAssetPrefixPath || ''; const assetPrefix = process.env._sentryAssetPrefix || globalWithInjectedValues._sentryAssetPrefix; - const basePath = process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; + const basePath = getInjectedBasePath(); const experimentalThirdPartyOriginStackFrames = process.env._experimentalThirdPartyOriginStackFrames === 'true' || globalWithInjectedValues._experimentalThirdPartyOriginStackFrames === 'true'; diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 9f16580546c7..a0a2dadc3e01 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { debug, getMainCarrier, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; +import { debug, getMainCarrier, getTunnelPath, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { close, getClient, getIsolationScope, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; @@ -238,6 +238,36 @@ describe('Client init()', () => { }); }); + describe('managed tunnel', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("serves `tunnel: true` under the app's `basePath`", () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: true }); + + expect(client?.getOptions().tunnel).toBe(`/docs${getTunnelPath(TEST_DSN)}`); + }); + + it('keeps a `basePath` the user set', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: { basePath: '/shop' } }); + + expect(client?.getOptions().tunnel).toBe(`/shop${getTunnelPath(TEST_DSN)}`); + }); + + it('keeps a `tunnel` URL as written', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: '/monitoring' }); + + expect(client?.getOptions().tunnel).toBe('/monitoring'); + }); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); From 579a3d9e45126603327e79eb50dc5f504e5ab526 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 10:14:00 -0400 Subject: [PATCH 18/19] feat(nextjs): Serve the managed tunnel under the app's `basePath` on the server `withSentryConfig` now injects `basePath` into production server and edge builds, not only in dev, and the server, edge and Cloudflare inits pass it to `setManagedTunnelDefaults`. `serveTunnelIntegration` then matches `//`, like the browser sends. --- packages/nextjs/src/client/index.ts | 9 +----- packages/nextjs/src/cloudflare/index.ts | 4 +++ .../devErrorSymbolicationEventProcessor.ts | 4 +-- packages/nextjs/src/common/utils/basePath.ts | 13 ++++++++ .../turbopack/constructTurbopackConfig.ts | 1 + .../turbopack/generateValueInjectionRules.ts | 7 +++++ packages/nextjs/src/config/webpack.ts | 3 +- packages/nextjs/src/edge/index.ts | 5 ++++ packages/nextjs/src/server/index.ts | 5 ++++ .../generateValueInjectionRules.test.ts | 15 ++++++++++ .../webpack/constructWebpackConfig.test.ts | 21 +++++++++++++ packages/nextjs/test/edgeSdk.test.ts | 30 ++++++++++++++++++- packages/nextjs/test/serverSdk.test.ts | 30 ++++++++++++++++++- 13 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 packages/nextjs/src/common/utils/basePath.ts diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 2327a5ab4955..c3d9361201d7 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -15,6 +15,7 @@ import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getClientVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { createNextRouteProvider } from './routing/routeProvider'; @@ -30,7 +31,6 @@ export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrume const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesAssetPrefixPath: string; _sentryAssetPrefix?: string; - _sentryBasePath?: string; _sentryRelease?: string; _experimentalThirdPartyOriginStackFrames?: string; }; @@ -97,13 +97,6 @@ export function init(options: BrowserOptions): Client | undefined { return client; } -/** - * The app's `basePath`, which `withSentryConfig` injects. - */ -function getInjectedBasePath(): string | undefined { - return process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; -} - function getDefaultIntegrations(options: BrowserOptions): Integration[] { const customDefaultIntegrations = getReactDefaultIntegrations(options); // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index 9c43cff4b5a3..3eb6c9f4296e 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -6,7 +6,9 @@ import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; @@ -69,6 +71,8 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; + // Read per request: the global is set once `instrumentation` loads, which can be after the Worker entry. + setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index f70990a173e8..46d491457c74 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -3,6 +3,7 @@ import { debug, GLOBAL_OBJ, parseSemver, suppressTracing } from '@sentry/core'; import type { StackFrame } from 'stacktrace-parser'; import * as stackTraceParser from 'stacktrace-parser'; import { DEBUG_BUILD } from './debug-build'; +import { getInjectedBasePath } from './utils/basePath'; import { URL_FULL } from '@sentry/conventions/attributes'; type OriginalStackFrameResponse = { @@ -12,7 +13,6 @@ type OriginalStackFrameResponse = { }; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryBasePath?: string; _sentryNextJsVersion: string | undefined; }; @@ -21,7 +21,7 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { * Returns only the base path when running in the browser (client-side) for relative URLs. */ function getDevServerBaseUrl(): string { - let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; + let basePath = getInjectedBasePath() ?? ''; // Prefix the basepath with a slash if it doesn't have one if (basePath !== '' && !basePath.match(/^\//)) { diff --git a/packages/nextjs/src/common/utils/basePath.ts b/packages/nextjs/src/common/utils/basePath.ts new file mode 100644 index 000000000000..b3dec3d72e9a --- /dev/null +++ b/packages/nextjs/src/common/utils/basePath.ts @@ -0,0 +1,13 @@ +import { GLOBAL_OBJ } from '@sentry/core'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath?: string; +}; + +/** + * The app's `basePath`, which `withSentryConfig` injects. The client bundle reads it from `env`. On the server, + * `@sentry/nextjs` is external, so `env` isn't inlined there and it reads the injected global instead. + */ +export function getInjectedBasePath(): string | undefined { + return process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 391dee0e39f1..23c8d59d0576 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -59,6 +59,7 @@ export function constructTurbopackConfig({ nextJsVersion, tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), vercelCronsConfig, + basePath: userNextConfig.basePath, }); for (const { matcher, rule } of valueInjectionRules) { diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 22c81f00102b..b334a096b1f1 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -13,11 +13,13 @@ export function generateValueInjectionRules({ nextJsVersion, tunnelRouteValues, vercelCronsConfig, + basePath, }: { routeManifest?: RouteManifest; nextJsVersion?: string; tunnelRouteValues?: TunnelRouteInjectedValues; vercelCronsConfig?: VercelCronsConfig; + basePath?: string; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; const isomorphicValues: Record = { ...tunnelRouteValues }; @@ -29,6 +31,11 @@ export function generateValueInjectionRules({ isomorphicValues._sentryNextJsVersion = nextJsVersion; } + // The server reads it to serve the managed tunnel under the base path. + if (basePath) { + isomorphicValues._sentryBasePath = basePath; + } + if (routeManifest) { clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index b7c1efa5cb65..0b885b50a2a2 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -714,7 +714,8 @@ function addValueInjectionLoader({ // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode // Only inject if release creation is not explicitly disabled (to maintain build determinism) SENTRY_RELEASE: releaseToInject && !buildContext.dev ? { id: releaseToInject } : undefined, - _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, + // The server reads it to serve the managed tunnel under the base path, dev symbolication to reach the dev server. + _sentryBasePath: userNextConfig.basePath, // This is used to determine version-based dev-symbolication behavior _sentryNextJsVersion: nextJsVersion, }; diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index e479fa45bd47..2eba3077389a 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -15,7 +15,9 @@ import { } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { ATTR_NEXT_PAGES_API_ROUTE_TYPE, @@ -114,6 +116,9 @@ export function init(options: VercelEdgeOptions = {}): void { ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. + setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); + // Before the Cloudflare check, so that the events of the client of `withSentry` get the tag too. try { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 3a1bf1ab9250..57f2d0312900 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -12,7 +12,9 @@ import { } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; @@ -159,6 +161,9 @@ export function init(options: NodeOptions): NodeClient | undefined { opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. + setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); + if (DEBUG_BUILD && opts.debug) { debug.enable(); } diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d68c002f298b..f1394690a417 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -332,6 +332,21 @@ describe('generateValueInjectionRules', () => { }); }); + describe('with basePath', () => { + it('injects the base path into the client and server bundles', () => { + const result = generateValueInjectionRules({ basePath: '/docs' }); + + const injectedValues = result.map(({ matcher, rule }) => [ + matcher, + (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, + ]); + expect(injectedValues).toEqual([ + ['**/instrumentation-client.*', { _sentryBasePath: '/docs' }], + ['**/instrumentation.*', { _sentryBasePath: '/docs', __SENTRY_SERVER_MODULES__: expect.any(Object) }], + ]); + }); + }); + describe('path resolution', () => { it('should call path.resolve with correct arguments', () => { const pathResolveSpy = vi.spyOn(path, 'resolve'); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 766a05f583c8..5c860d18cb31 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -864,3 +864,24 @@ describe('constructWebpackConfigFunction()', () => { }); }); }); + +describe('basePath value injection', () => { + it.each([ + ['server', serverWebpackConfig, serverBuildContext], + ['edge', serverWebpackConfig, edgeBuildContext], + ])( + 'injects the base path into production %s builds', + async (_target, incomingWebpackConfig, incomingWebpackBuildContext) => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig: { ...userNextConfig, basePath: '/docs' }, + incomingWebpackConfig, + incomingWebpackBuildContext, + }); + + const valueInjectionLoader = finalWebpackConfig.module.rules + .flatMap(rule => rule.use ?? []) + .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); + expect(valueInjectionLoader?.options).toMatchObject({ values: { _sentryBasePath: '/docs' } }); + }, + ); +}); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index a3fb9dacc7b8..c597d17a2149 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -1,8 +1,13 @@ import type { Integration } from '@sentry/core'; -import { getGlobalScope, getMainCarrier, GLOBAL_OBJ, withIsolationScope } from '@sentry/core'; +import { getGlobalScope, getMainCarrier, getTunnelPath, GLOBAL_OBJ, withIsolationScope } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _INTERNAL_resetManagedTunnelDefaults, + handleTunnelRequestIfMatched, + serveTunnelIntegration, +} from '@sentry/core/server'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/edge'; @@ -11,6 +16,8 @@ import { init } from '../src/edge'; const vercelEdgeInit = vi.spyOn(SentryVercelEdge, 'init'); +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { return integrations.find(integration => integration.name === name); } @@ -89,6 +96,27 @@ describe('Edge init()', () => { expect(getGlobalScope().getScopeData().tags.turbopack).toBe(true); }); + describe('managed tunnel', () => { + afterEach(() => { + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath = '/docs'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); + + const tunnelPath = getTunnelPath(TEST_DSN); + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + }); + describe('integrations', () => { // Options passed by `@sentry/nextjs`'s `init` to `@sentry/vercel-edge`'s `init` after modifying them type ModifiedInitOptions = { integrations?: Integration[]; defaultIntegrations: Integration[] }; diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 7ef42977be47..59d9ef881391 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -1,9 +1,14 @@ import type { Integration } from '@sentry/core'; -import { GLOBAL_OBJ, getMainCarrier, withIsolationScope } from '@sentry/core'; +import { GLOBAL_OBJ, getMainCarrier, getTunnelPath, withIsolationScope } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import { close, getCurrentScope, getGlobalScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _INTERNAL_resetManagedTunnelDefaults, + handleTunnelRequestIfMatched, + serveTunnelIntegration, +} from '@sentry/core/server'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; import type * as NextSentryPropagatorModule from '../src/server/nextSentryPropagator'; @@ -19,6 +24,8 @@ vi.mock('../src/server/nextSentryPropagator', async importOriginal => ({ const nodeInit = vi.spyOn(SentryNode, 'init'); +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { return integrations.find(integration => integration.name === name); } @@ -97,6 +104,27 @@ describe('Server init()', () => { }); }); + describe('managed tunnel', () => { + afterEach(() => { + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath = '/docs'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); + + const tunnelPath = getTunnelPath(TEST_DSN); + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + }); + it("doesn't reinitialize the node SDK if already initialized", () => { expect(nodeInit).toHaveBeenCalledTimes(0); init({}); From 09ad112749084e692ff1fafe2f04d93b30dba9de Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 30 Oct 2026 11:01:30 -0400 Subject: [PATCH 19/19] test(nextjs): Cover the managed tunnel under a base path --- .../instrumentation-client.ts | 3 +- .../nextjs-16-tunnel/next.config.ts | 7 +- .../nextjs-16-tunnel/package.json | 11 ++ .../nextjs-16-tunnel/sentry-dsn.ts | 6 +- .../nextjs-16-tunnel/sentry.edge.config.ts | 3 +- .../nextjs-16-tunnel/sentry.server.config.ts | 3 +- .../tests/tunnel-managed.test.ts | 108 ++++++++++++++++++ .../tests/tunnel-route.test.ts | 5 + .../tunnel-middleware/middleware.ts | 16 +++ 9 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 84b61b956e4a..3762bb3d7d8c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // The other variants use the tunnelRoute of withSentryConfig + tunnel: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? true : undefined, tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts index ac79f882a572..e2e151944c8b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts @@ -1,9 +1,12 @@ import { withSentryConfig } from '@sentry/nextjs/config'; import type { NextConfig } from 'next'; -const nextConfig: NextConfig = {}; +// The managed variants serve the managed tunnel under a base path, without a tunnel route. +const isManagedMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed'; + +const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs' } : {}; export default withSentryConfig(nextConfig, { silent: true, - tunnelRoute: true, + tunnelRoute: isManagedMode ? undefined : true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 4e887a8425ec..938e3ddbef8f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -17,6 +17,7 @@ "test:build-webpack": "pnpm install && pnpm build-webpack", "test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build", "test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack", + "test:build-middleware-webpack": "pnpm install && rm proxy.ts && cp tunnel-middleware/middleware.ts middleware.ts && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", @@ -72,6 +73,16 @@ "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack", "label": "nextjs-16-tunnel (handler, webpack, edge)", "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build", + "label": "nextjs-16-tunnel (managed, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build-middleware-webpack", + "label": "nextjs-16-tunnel (managed, webpack, edge middleware)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert-webpack" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts index fa0ad467f9bc..59c72c103e03 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts @@ -1,6 +1,8 @@ -// The handler variants send the server events to the event proxy too, so the tests also see server spans. +// The handler and managed variants send the server events to the event proxy too, so the tests also see server +// spans. export const SENTRY_DSN = - process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' || + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? 'http://public@localhost:3031/1337' : // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs 'https://public@o12345.ingest.us.sentry.io/67890'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts index 0333aa29412b..4a999c80e920 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // Serves the managed tunnel of the managed variants + integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1.0, // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts index 0333aa29412b..4a999c80e920 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // Serves the managed tunnel of the managed variants + integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1.0, // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts new file mode 100644 index 000000000000..6bcf452dff80 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; +import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { Event, SerializedStreamedSpan } from '@sentry/core'; + +test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'managed', 'Only the managed variants use it'); + +const BASE_PATH = '/docs'; + +function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { + const attribute = span.attributes[key]; + return attribute?.type === 'string' ? attribute.value : undefined; +} + +function isTunnelPath(pathname: string): boolean { + return /^\/docs\/[a-z0-9]+$/.test(pathname); +} + +test('sends the pageload span through the managed tunnel under the base path', async ({ page, baseURL }) => { + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + const tunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto(BASE_PATH); + + const tunnelResponse = await tunnelResponsePromise; + const pageloadSpan = await pageloadSpanPromise; + + expect(isTunnelPath(new URL(tunnelResponse.url()).pathname)).toBe(true); + expect(tunnelResponse.status()).toBe(200); + expect(pageloadSpan.status).toBe('ok'); +}); + +test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel managed cookie test', + ), + ); + + await page.goto(BASE_PATH); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel managed cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel managed cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(isTunnelPath(new URL(tunnelRequest.url()).pathname)).toBe(true); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); +}); + +test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every span that the proxy received before the barrier span. + const spansBeforeBarrier: SerializedStreamedSpan[] = []; + const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { + spansBeforeBarrier.push(...spans); + return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); + }); + const pageloadTunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto(BASE_PATH); + await pageloadTunnelResponsePromise; + // The first flush sends the tunnel spans, the second one the barrier span of the first flush. + await page.request.get(`${BASE_PATH}/api/flush`); + await page.request.get(`${BASE_PATH}/api/flush`); + await barrierSpanPromise; + + // The middleware span of the page request arrived, so the ones of the tunnel requests would have too. + expect(spansBeforeBarrier.some(span => span.name === 'middleware GET')).toBe(true); + + const postSpans = spansBeforeBarrier.filter( + span => + span.name.includes('POST') || + getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST' || + getStringAttribute(span, 'http.method') === 'POST', + ); + const ingestSpans = spansBeforeBarrier.filter( + span => + getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).toEqual([]); + expect(ingestSpans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index e0c1b5e273ae..eb27dc1af3e3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -8,6 +8,11 @@ function getStringAttribute(span: SerializedStreamedSpan, key: string): string | return attribute?.type === 'string' ? attribute.value : undefined; } +test.skip( + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed', + 'The managed variants have no tunnel route, see tunnel-managed.test.ts', +); + test('Tunnel route should proxy pageload span to Sentry', async ({ page }) => { // Wait for the pageload span to be sent through the tunnel const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts new file mode 100644 index 000000000000..49cfd8ee0af2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(request); + if (tunnelResponse) { + return tunnelResponse; + } + + return NextResponse.next(); +} + +export const config = { + matcher: '/:path*', +};