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/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, 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/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/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/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/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/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/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/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 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/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/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 66b5de50861b..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); } @@ -228,7 +230,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/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/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/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, }; 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'] }), }); }); 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/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..d4b077850e51 100644 --- a/packages/aws-serverless/src/run-lambda-handler.ts +++ b/packages/aws-serverless/src/run-lambda-handler.ts @@ -8,7 +8,10 @@ import type { Handler } from 'aws-lambda'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; +import { + _INTERNAL_parseLambdaHandlerString as parseHandlerString, + _INTERNAL_resolveLambdaHandlerFile as resolveHandlerFile, +} from '@sentry/node'; import { instrumentHandler } from './instrumentHandler'; function fail(message: string): never { diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 539389f4d576..1f93ecd908fc 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -1,6 +1,14 @@ 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'; @@ -192,6 +200,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 = _INTERNAL_answerLambdaTunnelEvent(event); + if (tunnelResult) { + return tunnelResult as Promise; + } + context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; timeoutWarningTimer = setupTimeoutWarning(context, options); @@ -244,6 +258,11 @@ function wrapStreamingHandler( responseStream: Parameters>[1], context: Context, ): Promise => { + const tunnelAnswer = _INTERNAL_answerStreamingLambdaTunnelEvent(event, responseStream); + if (tunnelAnswer) { + return tunnelAnswer as Promise; + } + context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; timeoutWarningTimer = setupTimeoutWarning(context, options); 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/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/tunnelServer.ts b/packages/bun/src/tunnelServer.ts new file mode 100644 index 000000000000..a03a38a657e5 --- /dev/null +++ b/packages/bun/src/tunnelServer.ts @@ -0,0 +1,181 @@ +import * as http from 'node:http'; +import * as https from 'node:https'; +import { _INTERNAL_normalizeTunnelBasePath, debug, getClient } from '@sentry/core'; +import { + _INTERNAL_getTunnelPaths as getTunnelPaths, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, +} 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; + +/** + * 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 function instrumentTunnelServers(): void { + patchBunServe(); + patchHttpServer(); +} + +/** + * 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/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/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/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/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..a358404bace2 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -1,45 +1,26 @@ 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 { 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 { - 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'; -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 { @@ -87,7 +68,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 +77,15 @@ 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; + // 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))); } - 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 +96,50 @@ 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, + // 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, + 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-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)}...`, + }), + ); +}); 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/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/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/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 62144a180134..3f0c97bf9a50 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,10 +1,14 @@ 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'; 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'; @@ -16,11 +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 @@ -42,8 +53,9 @@ export function setServedTunnel( basePath: tunnelOptions.basePath, allowedDsns, forward, + intercept: tunnelOptions.intercept !== false, }; - servedTunnels.set(client, servedTunnel); + getManagedTunnelState().served.set(client, servedTunnel); return servedTunnel; } @@ -67,7 +79,7 @@ function getServedTunnel(): ServedTunnel | undefined { return undefined; } - const servedTunnel = servedTunnels.get(client); + const servedTunnel = getManagedTunnelState().served.get(client); if (servedTunnel) { return servedTunnel; } @@ -223,22 +235,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/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/tunnelServer.ts b/packages/deno/src/tunnelServer.ts new file mode 100644 index 000000000000..a2dd95dbd258 --- /dev/null +++ b/packages/deno/src/tunnelServer.ts @@ -0,0 +1,85 @@ +import { subscribe } from 'node:diagnostics_channel'; +import { debug } from '@sentry/core'; +import { + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_instrumentTunnelServer as instrumentTunnelServer, + HTTP_ON_SERVER_REQUEST, +} from '@sentry/core/server'; + +type ServeHandler = (request: Request, info: Deno.ServeHandlerInfo) => Response | Promise; + +let hasPatchedDenoServe = false; +let isSubscribed = false; + +/** + * 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 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 + * 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/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/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(); + } +}); 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/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..deaaeee4db86 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -184,6 +184,15 @@ 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 { + 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/aws-serverless/src/handlerResolution.ts b/packages/node/src/lambda/handlerResolution.ts similarity index 100% rename from packages/aws-serverless/src/handlerResolution.ts rename to packages/node/src/lambda/handlerResolution.ts 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/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/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts new file mode 100644 index 000000000000..fc4944b2b4f0 --- /dev/null +++ b/packages/node/src/lambda/tunnel.ts @@ -0,0 +1,201 @@ +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, + }; +} + +// 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..bbc3b0152713 --- /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/loadHandler'; +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 57cf3871256a..3055a04728b6 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -41,6 +41,8 @@ import { getSpotlightConfig } from '../utils/spotlight'; 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'; /** @@ -133,6 +135,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 @@ -182,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 */ 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/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/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/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 { 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/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..160948b7ed46 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,9 +26,12 @@ import { startSpanManual, winterCGHeadersToDict, winterCGRequestToRequestData, + withIsolationScope, + withScope, } from '@sentry/core'; import type { ServerRequestOptions } from '@sentry/core/server'; import { + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, classifyResponseStreaming, getClientIPAddress, isStaticAssetRequest, @@ -45,14 +49,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; } /** @@ -73,7 +84,24 @@ 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) { + captureException(error, { mechanism }); + } + }; const endSession = options?.sessions ? startRequestSession(client, { @@ -81,6 +109,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 +125,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 +250,8 @@ export async function wrapServerRequestHandler { + !!response && isStreamingResponse(response), + inRequestScope(() => { if (onSpanEnd && response) { safeCallback( DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', @@ -231,15 +276,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 +334,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); + }); }); 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'); - }); });