diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index e1692463acd7..69a5899ae619 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -164,6 +164,10 @@ export { tediousIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, vercelAIIntegration, withActiveSpan, diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index e993fb34561b..410fe0c9a8be 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -26,7 +26,10 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server'; +import { + flushIfServerless, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, +} from '@sentry/core/server'; import { captureException, continueTrace, @@ -87,7 +90,7 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } - const tunnelResponse = handleTunnelRequestIfMatched(ctx.request); + const tunnelResponse = handleServedTunnelRequest(ctx.request); if (tunnelResponse) { return tunnelResponse; } diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index de4026d0b70f..fd9f0dc2a139 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -39,7 +39,7 @@ describe('sentryMiddleware with the managed tunnel', () => { const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); const tunnelResponse = new Response('forwarded'); const matcherSpy = vi - .spyOn(SentryCoreServer, 'handleTunnelRequestIfMatched') + .spyOn(SentryCoreServer, '_INTERNAL_handleServedTunnelRequest') .mockReturnValue(Promise.resolve(tunnelResponse)); const next = vi.fn(); const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index f5877b600a13..095858178815 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -152,6 +152,10 @@ export { systemErrorIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, instrumentSupabaseClient, @@ -193,6 +197,7 @@ export { eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; export { captureConsoleIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 24ddbe3ec328..763082aba7cd 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -172,6 +172,10 @@ export { spanToBaggageHeader, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, instrumentSupabaseClient, @@ -214,6 +218,7 @@ export { eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; export { captureConsoleIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index dc7312cb3685..60a32d7c5bbf 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -110,7 +110,16 @@ export { withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { createFlueInstrumentation, instrumentPostgresJsSql } from '@sentry/server-utils'; export type { FlueOptions } from '@sentry/server-utils'; diff --git a/packages/cloudflare/test/tunnelExports.test.ts b/packages/cloudflare/test/tunnelExports.test.ts new file mode 100644 index 000000000000..c6de5ddce836 --- /dev/null +++ b/packages/cloudflare/test/tunnelExports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as Sentry from '../src'; +import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; + +describe('managed tunnel exports', () => { + it.each([ + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', + 'getTunnelPath', + 'handleTunnelRequest', + ])('exports %s for wiring the tunnel by hand', name => { + expect(typeof (Sentry as Record)[name]).toBe('function'); + }); + + it('exports the tunnel types', () => { + const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; + const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; + const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; + + expect([tunnelOptions, request, handleOptions]).toHaveLength(3); + }); +}); diff --git a/packages/core/src/integrations/serveTunnel.ts b/packages/core/src/integrations/serveTunnel.ts index 74b38499dff2..9ee2d81334c1 100644 --- a/packages/core/src/integrations/serveTunnel.ts +++ b/packages/core/src/integrations/serveTunnel.ts @@ -1,7 +1,8 @@ import { defineIntegration } from '../integration'; import type { IntegrationFn } from '../types/integration'; import type { ServerTunnelOptions } from '../types/options'; -import { enableManagedTunnel, SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/managedTunnelDefaults'; +import { enableManagedTunnel } from '../utils/managedTunnelDefaults'; +import { SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/tunnel'; import { forwardTunnelRequest } from '../utils/tunnelForward'; const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 6452fc7b81a9..08c2b3e99a55 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -9,10 +9,14 @@ export { ServerRuntimeClient } from './server-runtime-client'; export type { TunnelRequestLike } from './utils/tunnel'; export { getTunnelPaths as _INTERNAL_getTunnelPaths, - handleTunnelRequestIfMatched, + handleServedTunnelRequest as _INTERNAL_handleServedTunnelRequest, + isServedTunnelRequest as _INTERNAL_isServedTunnelRequest, isTunnelRequest, } from './utils/tunnel'; -export { forwardTunnelRequest as _INTERNAL_forwardTunnelRequest } from './utils/tunnelForward'; +export { + forwardTunnelRequest as _INTERNAL_forwardTunnelRequest, + handleTunnelRequestIfMatched, +} from './utils/tunnelForward'; export { serveTunnelIntegration } from './integrations/serveTunnel'; export { _INTERNAL_resetManagedTunnelDefaults, diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index f9ee1c933169..c78cdd55136d 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -1,13 +1,11 @@ import type { Client } from '../client'; -import type { Integration } from '../types/integration'; import { getClient } from '../currentScopes'; import type { IgnoreSpanFilter, ServerTunnelOptions } from '../types/options'; import type { TunnelForwarder } from './tunnel'; import { + getServeTunnelForwarder, getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, - resolveAllowedDsns, - resolveTunnelPaths, setServedTunnel, } from './tunnel'; @@ -29,12 +27,6 @@ interface ManagedTunnelDefaults { basePath?: string; } -/** - * The name of `serveTunnelIntegration`, which carries the forwarder. Kept here so this module doesn't import the - * integration, and with it the forwarder, into apps that don't use it. - */ -export const SERVE_TUNNEL_INTEGRATION_NAME = 'ServeTunnel'; - let managedTunnelDefaults: ManagedTunnelDefaults | undefined; const tunnelForwarders = new WeakMap(); @@ -78,11 +70,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void { 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. const forward = - client && - (tunnelForwarders.get(client) ?? - client.getIntegrationByName?.(SERVE_TUNNEL_INTEGRATION_NAME) - ?.forward ?? - managedTunnelDefaults?.forward); + client && (tunnelForwarders.get(client) ?? getServeTunnelForwarder(client) ?? managedTunnelDefaults?.forward); if (!client || !options || !tunnelOptions || !forward) { return; } @@ -93,9 +81,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void { ? { ...tunnelOptions, basePath } : tunnelOptions; options._managedTunnel = resolved; - const allowedDsns = resolveAllowedDsns(resolved.allowedDsns, client.getDsn()); - const paths = resolveTunnelPaths(resolved, allowedDsns); - setServedTunnel(client, { paths, allowedDsns, forward }); + const { paths } = setServedTunnel(client, resolved, forward); const previousFilters = installedSpanFilters.get(client) ?? []; const spanFilters = getTunnelIgnoreSpanFilters(resolved, paths); diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5c0c805e531a..62144a180134 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,4 +1,5 @@ import type { Client } from '../client'; +import type { Integration } from '../types/integration'; import { getClient } from '../currentScopes'; import { URL_PATH } from '@sentry/conventions/attributes'; import type { DsnComponents, DsnLike } from '../types/dsn'; @@ -13,27 +14,68 @@ import { parseUrl } from './url'; export type TunnelForwarder = (request: Request, allowedDsns: Array) => Promise; /** - * A server client's tunnel paths, allowed DSNs and forwarder, computed once per client. + * A server client's tunnel paths, base path, allowed DSNs and forwarder, computed once per client. */ -interface ServedTunnel { - paths: string[]; - allowedDsns: string[]; +interface ServedTunnel extends TunnelMatch { forward: TunnelForwarder; } const servedTunnels = new WeakMap(); /** - * Stores a server client's computed tunnel paths and allowed DSNs. + * The name of `serveTunnelIntegration`, which carries the forwarder. Kept here so this module doesn't import the + * integration, and with it the forwarder, into apps that don't use it. + */ +export const SERVE_TUNNEL_INTEGRATION_NAME = 'ServeTunnel'; + +/** + * Computes a server client's served tunnel from its resolved options, and stores it. */ -export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { +export function setServedTunnel( + client: Client, + tunnelOptions: ServerTunnelOptions, + forward: TunnelForwarder, +): ServedTunnel { + const allowedDsns = resolveAllowedDsns(tunnelOptions.allowedDsns, client.getDsn()); + const servedTunnel = { + paths: resolveTunnelPaths(tunnelOptions, allowedDsns), + basePath: tunnelOptions.basePath, + allowedDsns, + forward, + }; servedTunnels.set(client, servedTunnel); + + return servedTunnel; } +/** + * The forwarder `serveTunnelIntegration` carries. Any copy of this module can reach it through the client. + */ +export function getServeTunnelForwarder(client: Client): TunnelForwarder | undefined { + return client.getIntegrationByName?.(SERVE_TUNNEL_INTEGRATION_NAME) + ?.forward; +} + +/** + * The active client's served tunnel. A copy of this module that didn't set it up rebuilds it once from what the + * client carries. Bundlers can give a framework's request hook its own copy, like Next.js's middleware next to its + * `instrumentation`. + */ function getServedTunnel(): ServedTunnel | undefined { const client = getClient(); + if (!client) { + return undefined; + } + + const servedTunnel = servedTunnels.get(client); + if (servedTunnel) { + return servedTunnel; + } - return client && servedTunnels.get(client); + const tunnelOptions = client.getOptions()._managedTunnel; + const forward = getServeTunnelForwarder(client); + + return tunnelOptions && forward ? setServedTunnel(client, tunnelOptions, forward) : undefined; } /** @@ -60,11 +102,11 @@ export function applyTunnelDefaults( } /** - * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. Browser and server derive it independently, - * and as a hash it gives ad-blocker filter lists nothing to match. + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`, by default the active client's. Browser and server + * derive it independently, and as a hash it gives ad-blocker filter lists nothing to match. */ -export function getTunnelPath(dsn: DsnLike): string | undefined { - const components = makeDsn(dsn); +export function getTunnelPath(dsn?: DsnLike): string | undefined { + const components = dsn ? makeDsn(dsn) : getClient()?.getDsn(); return components ? `/${fnv1a32(dsnToString(components)).toString(36)}` : undefined; } @@ -76,7 +118,6 @@ export interface TunnelRequestLike { method?: string; /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ url?: string; - headers?: Headers | Record; } /** @@ -133,29 +174,67 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { } /** - * Returns true if the request targets the active server client's managed tunnel. + * The paths, base path and allowed DSNs of a tunnel to match requests against. */ -export function isTunnelRequest(request: TunnelRequestLike): boolean { - if (request.method !== 'POST' || !request.url) { - return false; +interface TunnelMatch { + paths: string[]; + basePath?: string; + allowedDsns: string[]; +} + +/** + * Resolves the tunnel the public helpers match: `options`, else the one `serveTunnelIntegration` serves, else the + * path of the active client's own DSN. + */ +export function resolveTunnelMatch(options?: ServerTunnelOptions): TunnelMatch { + const servedTunnel = !options && getServedTunnel(); + if (servedTunnel) { + return servedTunnel; } - const tunnelOptions = getClient()?.getOptions()._managedTunnel; - if (!tunnelOptions) { + const allowedDsns = resolveAllowedDsns(options?.allowedDsns, getClient()?.getDsn()); + + return { paths: resolveTunnelPaths(options ?? {}, allowedDsns), basePath: options?.basePath, allowedDsns }; +} + +/** + * Returns true if the request is a POST to one of the tunnel's paths. + */ +export function matchesTunnel(request: TunnelRequestLike, tunnel: Omit): boolean { + if (request.method !== 'POST' || !request.url) { return false; } - const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnel.basePath); + + return path !== undefined && tunnel.paths.includes(path.replace(/\/$/, '')); +} + +/** + * Returns true if the request is a POST to the managed tunnel. The tunnel comes from `options`, else from + * `serveTunnelIntegration`, else it's the path of the active client's own DSN. + */ +export function isTunnelRequest(request: TunnelRequestLike, options?: ServerTunnelOptions): boolean { + return matchesTunnel(request, resolveTunnelMatch(options)); +} + +/** + * Returns true if the request targets the tunnel `serveTunnelIntegration` serves. Unlike {@link isTunnelRequest}, + * there's no fallback, so the framework SDKs serve nothing without the integration. + */ +export function isServedTunnelRequest(request: TunnelRequestLike): boolean { + const servedTunnel = getServedTunnel(); - return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, '')); + return !!servedTunnel && matchesTunnel(request, servedTunnel); } /** - * Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle. + * Forwards the request if it targets the tunnel `serveTunnelIntegration` serves, otherwise returns `undefined`. The + * framework SDKs answer tunnel requests with it. */ -export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { +export function handleServedTunnelRequest(request: Request): Promise | undefined { const servedTunnel = getServedTunnel(); - if (!servedTunnel?.allowedDsns.length || !isTunnelRequest(request)) { + if (!servedTunnel?.allowedDsns.length || !matchesTunnel(request, servedTunnel)) { return undefined; } diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 5334c53fa2ac..e30d7e21bfe4 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -3,25 +3,205 @@ import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; -import { resolveAllowedDsns } from './tunnel'; +import type { ServerTunnelOptions } from '../types/options'; +import { matchesTunnel, resolveAllowedDsns, resolveTunnelMatch } from './tunnel'; +/** + * Options for {@link handleTunnelRequest}. + */ export interface HandleTunnelRequestOptions { - /** Incoming request containing the Sentry envelope as its body */ + /** + * The incoming request. + * + * @deprecated Pass the request as the first argument of `handleTunnelRequest` instead. + */ request: Request; - /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ + /** + * DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. Defaults to the DSNs + * `serveTunnelIntegration` allows. + */ allowedDsns?: Array; } /** - * Core Sentry tunnel handler - framework agnostic. - * - * Validates the envelope DSN against allowed DSNs, then forwards the - * envelope to the Sentry ingest endpoint. + * The options of {@link handleTunnelRequest} that take the request as an argument. + */ +type TunnelForwardOptions = Omit; + +/** + * The parts of Node's `IncomingMessage` the tunnel reads. + */ +interface NodeTunnelRequest extends AsyncIterable { + method?: string; + url?: string; + headers: Record; +} + +/** + * The parts of Node's `ServerResponse` the tunnel writes. + */ +interface NodeTunnelResponse { + writeHead(statusCode: number, headers?: Record): unknown; + end(chunk: Uint8Array): unknown; +} + +/** + * Forwards a tunnel request to Sentry, if its envelope's DSN is the active client's or one of `allowedDsns`. + * It doesn't check the path: route the tunnel requests to it, e.g. on `getTunnelPath(dsn)`. * - * @returns A `Response` — either the upstream Sentry response on success, or an error response. + * @returns Sentry's response, or the response rejecting the request. + */ +export function handleTunnelRequest(request: Request, options?: TunnelForwardOptions): Promise; +/** + * Forwards a tunnel request from a Node server, like Express or `node:http`, and writes the answer to `response`. + * Mount it before body parsers, which would read the envelope first. + */ +export function handleTunnelRequest( + request: NodeTunnelRequest, + response: NodeTunnelResponse, + options?: TunnelForwardOptions, +): Promise; +/** + * The released form, kept until v12. Its `request` option is deprecated: pass the request as the first argument. + */ +export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise; +export function handleTunnelRequest( + requestOrOptions: Request | NodeTunnelRequest | HandleTunnelRequestOptions, + responseOrOptions?: NodeTunnelResponse | TunnelForwardOptions, + nodeOptions?: TunnelForwardOptions, +): Promise { + if (isNodeTunnelResponse(responseOrOptions)) { + const nodeResponse = responseOrOptions; + + return forwardTunnelRequest( + toFetchRequest(requestOrOptions as NodeTunnelRequest), + getAllowedDsns(nodeOptions), + ).then(fetchResponse => writeNodeResponse(nodeResponse, fetchResponse)); + } + + // The released `handleTunnelRequest({ request })` form. A `Request` has a `method`, the options object doesn't. + const isLegacyCall = 'request' in requestOrOptions && !('method' in requestOrOptions); + // oxlint-disable-next-line typescript/no-deprecated -- reads the released form to keep it working + const request = isLegacyCall ? requestOrOptions.request : (requestOrOptions as Request); + const options = isLegacyCall ? requestOrOptions : responseOrOptions; + + return forwardTunnelRequest(request, getAllowedDsns(options)); +} + +/** + * Forwards the request if it's a POST to the managed tunnel, otherwise returns `undefined` for the app to handle. The + * tunnel comes from `options`, else from `serveTunnelIntegration`, else it's the path of the active client's own DSN. + */ +export function handleTunnelRequestIfMatched( + request: Request, + options?: ServerTunnelOptions, +): Promise | undefined; +/** + * Forwards a tunnel request from a Node server, like Express or `node:http`, and writes the answer to `response`. + * Returns `undefined` for anything else, so the app can handle it. + */ +export function handleTunnelRequestIfMatched( + request: NodeTunnelRequest, + response: NodeTunnelResponse, + options?: ServerTunnelOptions, +): Promise | undefined; +export function handleTunnelRequestIfMatched( + request: Request | NodeTunnelRequest, + responseOrOptions?: NodeTunnelResponse | ServerTunnelOptions, + nodeOptions?: ServerTunnelOptions, +): Promise | undefined { + const response = isNodeTunnelResponse(responseOrOptions) ? responseOrOptions : undefined; + const tunnel = resolveTunnelMatch(response ? nodeOptions : (responseOrOptions as ServerTunnelOptions | undefined)); + if (!tunnel.allowedDsns.length || !matchesTunnel(request, tunnel)) { + return undefined; + } + + if (!response) { + return forwardTunnelRequest(request as Request, tunnel.allowedDsns); + } + + return forwardTunnelRequest(toFetchRequest(request as NodeTunnelRequest), tunnel.allowedDsns).then(fetchResponse => + writeNodeResponse(response, fetchResponse), + ); +} + +/** + * `allowedDsns` plus the server's own DSN. Without `allowedDsns`, the DSNs `serveTunnelIntegration` allows, so its + * options apply here too. + */ +function getAllowedDsns(options: TunnelForwardOptions | undefined): string[] { + return options?.allowedDsns + ? resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn()) + : resolveTunnelMatch().allowedDsns; +} + +/** + * The headers of Sentry's answer the browser SDK reads. The rest describe the connection to Sentry, which HTTP/2 + * rejects. */ -export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - return forwardTunnelRequest(options.request, resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn())); +const FORWARDED_RESPONSE_HEADERS = ['content-type', 'retry-after', 'x-sentry-rate-limits']; + +function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { + return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; +} + +function toFetchRequest(request: NodeTunnelRequest): 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. + if (value !== undefined && !name.startsWith(':')) { + headers.set(name, Array.isArray(value) ? value.join(', ') : value); + } + } + const iterator = request[Symbol.asyncIterator](); + const encoder = new TextEncoder(); + // Not `ReadableStream.from`, which Bun 1.3 lacks. + const body = new ReadableStream({ + async pull(controller) { + const { done, value } = await iterator.next(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(typeof value === 'string' ? encoder.encode(value) : value); + }, + // Node only reads the rest of a body nobody started reading, and unread bytes stall a keep-alive connection. + async cancel() { + while (!(await iterator.next()).done) { + // Drain. + } + }, + }); + + // 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/', { + method: 'POST', + headers, + body, + duplex: 'half', + } as RequestInit); +} + +async function writeNodeResponse(response: NodeTunnelResponse, fetchResponse: Response): Promise { + // Read before writing, so an answer from Sentry that breaks off still ends the browser's request. + const body = await fetchResponse.arrayBuffer().catch(() => undefined); + if (!body) { + response.writeHead(500); + response.end(new Uint8Array()); + return; + } + + const responseHeaders: Record = {}; + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = fetchResponse.headers.get(name); + if (value) { + responseHeaders[name] = value; + } + } + response.writeHead(fetchResponse.status, responseHeaders); + response.end(new Uint8Array(body)); } function withDecodedBodyHeaders(response: Response): Response { @@ -84,7 +264,7 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr * Returns the ingest URL for an envelope header line, or the rejecting response. */ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { - let envelopeHeader: { dsn?: string } | null; + let envelopeHeader: { dsn?: unknown } | null; try { envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); } catch { @@ -96,7 +276,7 @@ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): s } const dsn = envelopeHeader.dsn; - if (!dsn) { + if (!dsn || typeof dsn !== 'string') { return new Response('Invalid envelope: missing DSN', { status: 400 }); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 05dfa054e91b..f35bbf595bb5 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,3 +1,7 @@ +import * as http from 'node:http'; +import * as http2 from 'node:http2'; +import * as net from 'node:net'; +import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; import { getCurrentScope, setCurrentClient } from '../../../src'; @@ -9,13 +13,19 @@ import { getTunnelPath, getTunnelIgnoreSpanFilters, getTunnelPaths, - handleTunnelRequestIfMatched, + handleServedTunnelRequest, + isServedTunnelRequest, isTunnelRequest, normalizeTunnelBasePath, resolveAllowedDsns, resolveTunnelPaths, } from '../../../src/utils/tunnel'; -import { forwardTunnelRequest, handleTunnelRequest } from '../../../src/utils/tunnelForward'; +import type { HandleTunnelRequestOptions } from '../../../src/utils/tunnelForward'; +import { + forwardTunnelRequest, + handleTunnelRequest, + handleTunnelRequestIfMatched, +} from '../../../src/utils/tunnelForward'; import { serveTunnelIntegration } from '../../../src/integrations/serveTunnel'; import { _INTERNAL_resetManagedTunnelDefaults, @@ -72,6 +82,231 @@ function makeEndlessRequest(head: string, onCancel?: () => void): Request { } as RequestInit); } +describe('handleTunnelRequest call forms', () => { + // Captured before the stub, so the test can reach its own server. + const realFetch = globalThis.fetch; + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(() => Promise.resolve(new Response('forwarded', { status: 202 }))); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('forwards the DSNs `serveTunnelIntegration` allows when no `allowedDsns` are passed', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + + const fromIntegration = await handleTunnelRequest(makeEnvelopeRequest({ dsn: browserDsn })); + const explicit = await handleTunnelRequest(makeEnvelopeRequest({ dsn: browserDsn }), { + allowedDsns: ['https://other@o2.ingest.sentry.io/7'], + }); + getCurrentScope().setClient(undefined); + + expect(fromIntegration.status).toBe(202); + expect(explicit.status).toBe(403); + }); + + it('still takes the released options object', async () => { + // A typed variable, like code written against the released type. + // oxlint-disable-next-line typescript/no-deprecated -- the form under test + const options: HandleTunnelRequestOptions = { + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }; + const response = await handleTunnelRequest(options); + + expect(response.status).toBe(202); + }); + + it("forwards from a Node server's request and writes the answer to its response", async () => { + fetchMock.mockResolvedValue( + new Response('forwarded', { status: 202, headers: { 'x-sentry-rate-limits': '60:error', 'retry-after': '60' } }), + ); + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const response = await realFetch(`http://localhost:${port}/tunnel`, { method: 'POST', body: envelope }); + + expect(response.status).toBe(202); + expect(response.headers.get('x-sentry-rate-limits')).toBe('60:error'); + expect(response.headers.get('retry-after')).toBe('60'); + expect(await response.text()).toBe('forwarded'); + const [url, init] = fetchMock.mock.lastCall as unknown as [string, RequestInit]; + expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); + expect(await new Response(init.body).text()).toBe(envelope); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + + it('forwards from a Node HTTP/2 server, which rejects the connection headers of the answer', async () => { + fetchMock.mockResolvedValue( + new Response('forwarded', { status: 202, headers: { connection: 'keep-alive', 'keep-alive': 'timeout=5' } }), + ); + const server = http2.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const client = http2.connect(`http://localhost:${(server.address() as AddressInfo).port}`); + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const { status, body } = await new Promise<{ status: unknown; body: string }>(resolve => { + const req = client.request({ ':method': 'POST', ':path': '/tunnel', 'content-length': envelope.length }); + let responseStatus: unknown; + let responseBody = ''; + req.on('response', headers => (responseStatus = headers[':status'])); + req.on('data', chunk => (responseBody += chunk)); + req.on('end', () => resolve({ status: responseStatus, body: responseBody })); + req.end(envelope); + }); + + expect(status).toBe(202); + expect(body).toBe('forwarded'); + expect(fetchMock).toHaveBeenCalledOnce(); + } finally { + client.close(); + await new Promise(resolve => server.close(resolve)); + } + }); + + it('forwards a Node request whose Host header is no valid URL host', async () => { + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const statusLine = await new Promise((resolve, reject) => { + const socket = net.connect((server.address() as AddressInfo).port, () => { + socket.end(`POST /tunnel HTTP/1.1\r\nHost: a b<>\r\nContent-Length: ${envelope.length}\r\n\r\n${envelope}`); + }); + socket.on('data', data => resolve(String(data).split('\r\n')[0]!)); + socket.on('error', reject); + }); + + expect(statusLine).toBe('HTTP/1.1 202 Accepted'); + expect(fetchMock).toHaveBeenCalledOnce(); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + + it("answers a Node request with a 500 when Sentry's answer breaks off", async () => { + fetchMock.mockResolvedValue( + new Response( + new ReadableStream({ + pull(controller) { + controller.error(new Error('connection to Sentry dropped')); + }, + }), + ), + ); + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const response = await realFetch(`http://localhost:${port}/tunnel`, { method: 'POST', body: envelope }); + + expect(response.status).toBe(500); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + + it('rejects an envelope whose DSN is no string, also from a Node server', async () => { + const body = '{"dsn":{"toString":1}}\n{}'; + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + + try { + const fetchForm = await handleTunnelRequest( + new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': `${body.length}` }, + }), + { allowedDsns: [TEST_DSN] }, + ); + const nodeForm = await realFetch(`http://localhost:${(server.address() as AddressInfo).port}/tunnel`, { + method: 'POST', + body, + }); + + expect(fetchForm.status).toBe(400); + expect(nodeForm.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + + it('reads the rest of a rejected Node request, so the next request on the connection gets an answer', async () => { + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + const post = (body: string): Promise => + new Promise((resolve, reject) => { + http + .request({ port, agent, method: 'POST', path: '/tunnel' }, res => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }) + .on('error', reject) + .end(body); + }); + const foreign = serializeEnvelope(createEnvelope({ dsn: SERVER_DSN }, [])) as string; + + try { + expect(await post(`${foreign}\n${'a'.repeat(2 * 1024 * 1024)}`)).toBe(403); + expect(await post(foreign)).toBe(403); + } finally { + agent.destroy(); + await new Promise(resolve => server.close(resolve)); + } + }); + + it('rejects a Node request whose body a parser already read', async () => { + const server = http.createServer(async (req, res) => { + for await (const _chunk of req) { + // Like a body parser that ran first. + } + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const response = await realFetch(`http://localhost:${port}/tunnel`, { method: 'POST', body: envelope }); + + expect(response.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); +}); + describe('handleTunnelRequest', () => { let fetchMock: ReturnType; @@ -83,8 +318,7 @@ describe('handleTunnelRequest', () => { it('leaves the size limit to Sentry and passes its 413 back', async () => { fetchMock.mockResolvedValueOnce(new Response('Envelope too large', { status: 413 })); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, 1024 * 1024 * 1024), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, 1024 * 1024 * 1024), { allowedDsns: [TEST_DSN], }); @@ -93,13 +327,13 @@ describe('handleTunnelRequest', () => { }); it('returns 411 when the request has no Content-Length', async () => { - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + const result = await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(result.status).toBe(411); expect(fetchMock).not.toHaveBeenCalled(); @@ -114,10 +348,7 @@ describe('handleTunnelRequest', () => { const upstreamResponse = new Response('ok', { status: 200 }); fetchMock.mockResolvedValueOnce(upstreamResponse); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0]!; @@ -135,10 +366,7 @@ describe('handleTunnelRequest', () => { new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }), ); - const forwarded = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), - allowedDsns: [TEST_DSN], - }); + const forwarded = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(forwarded.headers.get('Content-Type')).toBe('application/json'); // The original encoding would make the browser decode it twice. @@ -152,14 +380,14 @@ describe('handleTunnelRequest', () => { ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { fetchMock.mockResolvedValueOnce(new Response('ok')); - await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, headers: { ...incomingHeaders, 'content-length': '1' }, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -181,14 +409,14 @@ describe('handleTunnelRequest', () => { referer: 'https://example.com/page', }; - await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, headers: { ...clientHeaders, cookie: 'session=secret', 'content-length': '1' }, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -199,10 +427,10 @@ describe('handleTunnelRequest', () => { }); it('rejects a disallowed DSN after reading only the envelope header', async () => { - const result = await handleTunnelRequest({ - request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest( + makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), + { allowedDsns: [TEST_DSN] }, + ); expect(result.status).toBe(403); expect(fetchMock).not.toHaveBeenCalled(); @@ -211,10 +439,10 @@ describe('handleTunnelRequest', () => { it('cancels the body of a rejected request', async () => { const onCancel = vi.fn(); - await handleTunnelRequest({ - request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), - allowedDsns: [TEST_DSN], - }); + await handleTunnelRequest( + makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), + { allowedDsns: [TEST_DSN] }, + ); expect(onCancel).toHaveBeenCalledOnce(); }); @@ -223,8 +451,7 @@ describe('handleTunnelRequest', () => { const upstreamResponse = new Response('ok'); fetchMock.mockResolvedValueOnce(upstreamResponse); - const result = await handleTunnelRequest({ - request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + const result = await handleTunnelRequest(makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), { allowedDsns: [TEST_DSN], }); @@ -232,20 +459,14 @@ describe('handleTunnelRequest', () => { }); it('returns 400 when the envelope header line exceeds the limit', async () => { - const result = await handleTunnelRequest({ - request: makeEndlessRequest(''), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(makeEndlessRequest(''), { allowedDsns: [TEST_DSN] }); expect(result.status).toBe(400); expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 500 when allowedDsns is empty', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), - allowedDsns: [], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [] }); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(500); @@ -254,10 +475,7 @@ describe('handleTunnelRequest', () => { }); it('returns 400 when the envelope has no DSN in the header', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({}), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({}), { allowedDsns: [TEST_DSN] }); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(400); @@ -266,14 +484,14 @@ describe('handleTunnelRequest', () => { }); it('returns 400 when the envelope body contains malformed JSON', async () => { - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + const result = await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{', headers: { 'content-length': '26' }, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(400); @@ -282,8 +500,7 @@ describe('handleTunnelRequest', () => { }); it('returns 403 when the envelope DSN is not in allowedDsns', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), { allowedDsns: [TEST_DSN], }); @@ -296,8 +513,7 @@ describe('handleTunnelRequest', () => { it('ignores an allowed DSN that cannot be parsed', async () => { const malformedDsn = 'not-a-valid-dsn'; - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: malformedDsn }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: malformedDsn }), { allowedDsns: [malformedDsn], }); @@ -308,8 +524,7 @@ describe('handleTunnelRequest', () => { it('matches an allowed DSN written with a secret key, which the envelope header leaves out', async () => { fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN.replace('@', ':secret@')], }); @@ -322,8 +537,7 @@ describe('handleTunnelRequest', () => { const upstreamResponse = new Response('ok', { status: 200 }); fetchMock.mockResolvedValueOnce(upstreamResponse); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [otherDsn, TEST_DSN], }); @@ -336,10 +550,7 @@ describe('handleTunnelRequest', () => { it('returns 500 when fetch throws a network error', async () => { fetchMock.mockRejectedValueOnce(new Error('Network failure')); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(500); @@ -354,7 +565,7 @@ describe('handleTunnelRequest', () => { return Promise.resolve(new Response('ok')); }); - await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); + await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(suppressed).toBe(true); }); @@ -363,14 +574,13 @@ describe('handleTunnelRequest', () => { setTestClient(TEST_DSN); fetchMock.mockResolvedValue(new Response('ok')); - const ownDsnResult = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), - allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], - }); - const unlistedDsnResult = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: 'https://other@o3.ingest.sentry.io/7' }), + const ownDsnResult = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], }); + const unlistedDsnResult = await handleTunnelRequest( + makeEnvelopeRequest({ dsn: 'https://other@o3.ingest.sentry.io/7' }), + { allowedDsns: ['https://browser@o2.ingest.sentry.io/42'] }, + ); expect(ownDsnResult.status).toBe(200); expect(unlistedDsnResult.status).toBe(403); @@ -380,7 +590,7 @@ describe('handleTunnelRequest', () => { setTestClient(TEST_DSN); fetchMock.mockResolvedValueOnce(new Response('ok')); - const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN })); expect(result.status).toBe(200); expect(fetchMock).toHaveBeenCalledWith( @@ -390,7 +600,7 @@ describe('handleTunnelRequest', () => { }); it('returns 500 when allowedDsns is omitted and there is no client', async () => { - const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN })); expect(result.status).toBe(500); expect(fetchMock).not.toHaveBeenCalled(); @@ -398,6 +608,14 @@ describe('handleTunnelRequest', () => { }); describe('getTunnelPath', () => { + it("defaults to the active client's DSN", () => { + setTestClient(TEST_DSN); + + expect(getTunnelPath()).toBe(getTunnelPath(TEST_DSN)); + getCurrentScope().setClient(undefined); + expect(getTunnelPath()).toBeUndefined(); + }); + it('returns a short opaque path that does not leak the DSN', () => { const path = getTunnelPath(TEST_DSN); @@ -451,12 +669,27 @@ describe('isTunnelRequest', () => { ); }); - it('uses the managed tunnel config of the active client when no options are passed', () => { + it("falls back to the active client's own DSN without `serveTunnelIntegration`, unlike the served-only matcher", () => { setTestClient(TEST_DSN); - expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); - setTestClient(TEST_DSN, {}); expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); + expect(isServedTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + }); + + it('matches the tunnel the options describe, with no client at all', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + + expect( + isTunnelRequest( + { method: 'POST', url: `/app${getTunnelPath(browserDsn)}` }, + { + allowedDsns: [browserDsn], + basePath: '/app', + }, + ), + ).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(browserDsn)! }, { path: '/monitor' })).toBe(false); }); it('matches nothing without a client', () => { @@ -627,6 +860,55 @@ describe('handleTunnelRequestIfMatched', () => { expect(request.bodyUsed).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); + it('forwards to the own DSN path without `serveTunnelIntegration`, which the framework SDKs ignore', async () => { + fetchMock.mockResolvedValue(new Response('ok')); + setTestClient(TEST_DSN); + const tunnelUrl = `http://localhost${getTunnelPath(TEST_DSN)}`; + + expect((await handleTunnelRequestIfMatched(makeEnvelopeRequest({ dsn: TEST_DSN }, tunnelUrl)))?.status).toBe(200); + expect(handleServedTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, tunnelUrl))).toBeUndefined(); + }); + + it('forwards the tunnel the options describe', async () => { + fetchMock.mockResolvedValue(new Response('ok')); + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN); + + const response = await handleTunnelRequestIfMatched( + makeEnvelopeRequest({ dsn: browserDsn }, 'http://localhost/monitor'), + { path: '/monitor', allowedDsns: [browserDsn] }, + ); + + expect(response?.status).toBe(200); + expect( + handleTunnelRequestIfMatched(makeEnvelopeRequest({ dsn: browserDsn }, 'http://localhost/other'), { + path: '/monitor', + }), + ).toBeUndefined(); + }); + + it("answers a Node server's tunnel requests and leaves the rest to the app", async () => { + fetchMock.mockResolvedValue(new Response('forwarded', { status: 202 })); + setTestClient(TEST_DSN); + const server = http.createServer((req, res) => { + if (!handleTunnelRequestIfMatched(req, res)) { + res.writeHead(200).end('app'); + } + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const tunnel = await nodeRequest(port, getTunnelPath(TEST_DSN)!, envelope); + const other = await nodeRequest(port, '/api', envelope); + + expect(tunnel).toEqual({ status: 202, body: 'forwarded' }); + expect(other).toEqual({ status: 200, body: 'app' }); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); }); describe('getTunnelIgnoreSpanFilter', () => { @@ -719,7 +1001,7 @@ describe('serveTunnelIntegration', () => { 'fetch', vi.fn(() => Promise.resolve(new Response('forwarded'))), ); - const response = await bundledTunnel.handleTunnelRequestIfMatched( + const response = await bundledTunnel.handleServedTunnelRequest( makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost/app${getTunnelPath(TEST_DSN)}`), ); vi.unstubAllGlobals(); @@ -728,6 +1010,28 @@ describe('serveTunnelIntegration', () => { expect(await response?.text()).toBe('forwarded'); }); + it('serves the tunnel from a copy of core that never set it up, like a separately bundled request hook', async () => { + const client = new ServerRuntimeClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, integrations: [serveTunnelIntegration({ basePath: '/app' })] }), + ); + setCurrentClient(client); + client.init(); + + vi.resetModules(); + const bundledTunnel = await import('../../../src/utils/tunnel'); + const tunnelUrl = `http://localhost/app${getTunnelPath(TEST_DSN)}`; + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const response = await bundledTunnel.handleServedTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, tunnelUrl)); + vi.unstubAllGlobals(); + + expect(bundledTunnel.isServedTunnelRequest({ method: 'POST', url: tunnelUrl })).toBe(true); + expect(bundledTunnel.isTunnelRequest({ method: 'POST', url: tunnelUrl })).toBe(true); + expect(await response?.text()).toBe('forwarded'); + }); + it('serves nothing for a `_managedTunnel` set without it, since only the integration brings the forwarder', () => { const client = new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })); setCurrentClient(client); @@ -735,9 +1039,7 @@ describe('serveTunnelIntegration', () => { expect(getTunnelPaths()).toEqual([]); expect( - handleTunnelRequestIfMatched( - makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`), - ), + handleServedTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`)), ).toBeUndefined(); }); }); @@ -873,3 +1175,16 @@ function isTunnelRequestFor( return isTunnelRequest(request); } + +/** POSTs `body` to a local server with `node:http`, since `fetch` is stubbed in these tests. */ +function nodeRequest(port: number, path: string, body: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request({ port, path, method: 'POST' }, res => { + let data = ''; + res.on('data', chunk => (data += chunk)); + res.on('end', () => resolve({ status: res.statusCode!, body: data })); + }); + req.on('error', reject); + req.end(body); + }); +} diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f0efd1331582..d26507f6988a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -111,7 +111,16 @@ export { consoleLoggingIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { instrumentPostgresJsSql, prismaIntegration, diff --git a/packages/deno/test/exports.test.ts b/packages/deno/test/exports.test.ts index 76b4cce1cd18..6ac1c5d92ddf 100644 --- a/packages/deno/test/exports.test.ts +++ b/packages/deno/test/exports.test.ts @@ -13,8 +13,13 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'growthbookIntegration', 'moduleMetadataIntegration', 'parameterize', + 'getTunnelPath', + 'handleTunnelRequest', // @sentry/core/server 'trpcMiddleware', + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', // @sentry/server-utils 'prismaIntegration', 'instrumentOpenAiClient', diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 76c135d6f3f4..abe6212f2728 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -143,6 +143,11 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, instrumentSupabaseClient, @@ -182,6 +187,7 @@ export { type BunRuntimeMetricsOptions, makeFetchTransport, } from '@sentry/bun'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/bun'; export type { Breadcrumb, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 3965a6b8113e..cd588df14aae 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -148,6 +148,10 @@ export { spanToBaggageHeader, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, systemErrorIntegration, @@ -194,6 +198,7 @@ export { eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; export { captureConsoleIntegration, diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index 09a2c7cdcc43..d86dd2d0a99d 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -43,7 +43,7 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions } // Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers. - return suppressTracing(() => handleTunnelRequest({ request, allowedDsns })); + return suppressTracing(() => handleTunnelRequest(request, { allowedDsns })); }, }; } diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 0b61322284aa..44ced3c5b3c3 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,5 +1,5 @@ import { - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { definePlugin } from 'nitro'; @@ -14,5 +14,5 @@ export default definePlugin(nitroApp => { // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); + nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 37cd9f5365d2..53f1b781d45c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -175,7 +175,16 @@ export { featureFlagsIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export type { Breadcrumb, diff --git a/packages/node/test/tunnelExports.test.ts b/packages/node/test/tunnelExports.test.ts new file mode 100644 index 000000000000..c6de5ddce836 --- /dev/null +++ b/packages/node/test/tunnelExports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as Sentry from '../src'; +import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; + +describe('managed tunnel exports', () => { + it.each([ + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', + 'getTunnelPath', + 'handleTunnelRequest', + ])('exports %s for wiring the tunnel by hand', name => { + expect(typeof (Sentry as Record)[name]).toBe('function'); + }); + + it('exports the tunnel types', () => { + const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; + const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; + const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; + + expect([tunnelOptions, request, handleOptions]).toHaveLength(3); + }); +}); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index e5ae50026c53..89b1a04e8958 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,6 +1,6 @@ import { - handleTunnelRequestIfMatched, - isTunnelRequest, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { eventHandler, toWebRequest } from 'h3'; @@ -22,12 +22,12 @@ export default (nitroApp => { route: '', handler: eventHandler(event => { // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isTunnelRequest({ method: event.method, url: event.path })) { + 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 handleTunnelRequestIfMatched(toWebRequest(event)); + 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 e27471be638f..ae4b451edc32 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,5 +1,5 @@ import { - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { useRuntimeConfig } from 'nitro/runtime-config'; @@ -16,5 +16,5 @@ export default ((nitroApp: NitroApp) => { // up a `NUXT_APP_BASE_URL` set at runtime. setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); + nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }) satisfies NitroAppPlugin; diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 821640eed21c..18b03a6d2c13 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,7 +1,7 @@ import { debug, getClient } from '@sentry/core'; import { _INTERNAL_getTunnelPaths as getTunnelPaths, - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -63,7 +63,7 @@ function makeTunnelRoute(id: string, getPath: () => string): Record - handleTunnelRequestIfMatched(request) ?? new Response(null, { status: 404 }), + handleServedTunnelRequest(request) ?? new Response(null, { status: 404 }), loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), }, }; diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 582607b00d58..a1aaa76b57aa 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -122,6 +122,10 @@ export { tediousIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index b70fe91e9b4c..f63578e2f0f9 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,7 +1,7 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; import { consoleSandbox } from '@sentry/core'; -import { handleTunnelRequestIfMatched } from '@sentry/core/server'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import type { OrchestrionChannelContext } from '@sentry/server-utils'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; @@ -95,7 +95,7 @@ function wrapFetchHandler(args: unknown[]): void { } args[0] = function sentryFetchHandler(this: unknown, request: Request, ...rest: unknown[]): unknown { - return handleTunnelRequestIfMatched(request) ?? handler.call(this, request, ...rest); + return handleServedTunnelRequest(request) ?? handler.call(this, request, ...rest); }; } diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index b5803af44dcb..4ca54f648383 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -10,7 +10,7 @@ import { updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; -import { handleTunnelRequestIfMatched } from '@sentry/core/server'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; @@ -26,7 +26,7 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { - const tunnelResponse = handleTunnelRequestIfMatched(context.request); + const tunnelResponse = handleServedTunnelRequest(context.request); if (tunnelResponse) { return tunnelResponse; } diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index c0d984e763f7..85133e8b3ecd 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -18,7 +18,7 @@ const tunnelResponse = new Response(null, { status: 200 }); // Answers only `TUNNEL_URL`, like a server with the managed tunnel at that path. vi.mock('@sentry/core/server', () => ({ - handleTunnelRequestIfMatched: (request: Request) => + _INTERNAL_handleServedTunnelRequest: (request: Request) => request.url === TUNNEL_URL ? Promise.resolve(tunnelResponse) : undefined, })); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index c86d426793e9..e0f1866e78fd 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Trace data as the SDK would produce it for the active request; empty when nothing is active. const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({}))); -const handleTunnelRequestIfMatched = vi.hoisted(() => +const handleServedTunnelRequest = vi.hoisted(() => vi.fn<(request: Request) => Promise | undefined>(() => undefined), ); @@ -16,7 +16,7 @@ vi.mock('@sentry/core', async importOriginal => ({ vi.mock('@sentry/core/server', async importOriginal => ({ ...(await importOriginal()), - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest: handleServedTunnelRequest, })); import { sentryRemixMiddleware } from '../../src/v3/server/middleware'; @@ -37,19 +37,19 @@ function contextFor(url: string, headers: Record = {}): RequestC describe('sentryRemixMiddleware', () => { beforeEach(() => { getTraceData.mockReturnValue({}); - handleTunnelRequestIfMatched.mockReturnValue(undefined); + handleServedTunnelRequest.mockReturnValue(undefined); }); it('answers tunnel requests before route matching and the rest of the chain', async () => { const tunnelResponse = new Response('forwarded'); - handleTunnelRequestIfMatched.mockReturnValue(Promise.resolve(tunnelResponse)); + 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(handleTunnelRequestIfMatched).toHaveBeenCalledWith(context.request); + expect(handleServedTunnelRequest).toHaveBeenCalledWith(context.request); expect(next).not.toHaveBeenCalled(); }); diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index 6832dd657fae..c6882b74082d 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,7 +1,7 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { - handleTunnelRequestIfMatched, - isTunnelRequest, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { eventHandler, toWebRequest } from 'h3'; @@ -25,13 +25,13 @@ export default (nitroApp => { route: '', handler: eventHandler(event => { // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isTunnelRequest({ method: event.method, url: event.path })) { + 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 handleTunnelRequestIfMatched(toWebRequest(event)); + return handleServedTunnelRequest(toWebRequest(event)); }), }); }) satisfies NitroAppPlugin; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 0e7cec54fa4f..b2d128847df8 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -126,6 +126,11 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts index 1ad7099f278b..0f03cef43745 100644 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -67,6 +67,20 @@ describe('SolidStart v1 tunnel plugin', () => { 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' }); diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 09df6d82e8c3..a910add9b0d1 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -21,7 +21,7 @@ import { } from '@sentry/core'; import { flushIfServerless, - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { getTunnelBasePath } from '../common/tunnel'; @@ -300,7 +300,7 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { - const tunnelResponse = handleTunnelRequestIfMatched(input.event.request); + const tunnelResponse = handleServedTunnelRequest(input.event.request); if (tunnelResponse) { return tunnelResponse; } diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 2b12f240379c..270abc2ae43c 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -127,6 +127,10 @@ export { tediousIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index e13be39ce91e..850fa3c8763e 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -82,6 +82,10 @@ export { startSpanManual, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, withActiveSpan, withIsolationScope, withMonitor, diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 7aa46d8b3037..1d4d09b80553 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -3,8 +3,8 @@ import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; import { _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, - handleTunnelRequestIfMatched, - isTunnelRequest, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import type { ServerEntry } from './wrapFetchWithSentry'; @@ -92,10 +92,7 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) ); } - return handleTunnelRequest({ - request, - allowedDsns, - }); + return handleTunnelRequest(request, { allowedDsns }); }, }, }; @@ -133,11 +130,11 @@ export function wrapServerEntryWithSentryTunnel( // 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' && isTunnelRequest({ method: 'POST', url: request.url })) { + if (request.method !== 'POST' && isServedTunnelRequest({ method: 'POST', url: request.url })) { return new Response(null, { status: 405, headers: { Allow: 'POST' } }); } - return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); + return handleServedTunnelRequest(request) ?? entry.fetch(request, ...rest); }, writable: true, configurable: true, diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts index 0016be5c0785..c4ef0481b765 100644 --- a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -39,12 +39,7 @@ describe('createSentryTunnelRoute', () => { const result = await route.handlers.POST({ request }); expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); - const [options] = handleTunnelRequestSpy.mock.calls[0]!; - expect(options).toEqual({ - request, - allowedDsns, - }); - expect(options.allowedDsns).toBe(allowedDsns); + expect(handleTunnelRequestSpy).toHaveBeenCalledWith(request, { allowedDsns }); expect(result).toBe(response); }); @@ -71,9 +66,7 @@ describe('createSentryTunnelRoute', () => { const result = await route.handlers.POST({ request }); expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); - const [options] = handleTunnelRequestSpy.mock.calls[0]!; - expect(options).toEqual({ - request, + expect(handleTunnelRequestSpy).toHaveBeenCalledWith(request, { allowedDsns: ['http://public@localhost:3031/1337'], }); expect(result).toBe(response); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 47abe7da7218..3177c2774979 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,7 +105,16 @@ export { withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/vercel-edge/test/tunnelExports.test.ts b/packages/vercel-edge/test/tunnelExports.test.ts new file mode 100644 index 000000000000..c6de5ddce836 --- /dev/null +++ b/packages/vercel-edge/test/tunnelExports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as Sentry from '../src'; +import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; + +describe('managed tunnel exports', () => { + it.each([ + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', + 'getTunnelPath', + 'handleTunnelRequest', + ])('exports %s for wiring the tunnel by hand', name => { + expect(typeof (Sentry as Record)[name]).toBe('function'); + }); + + it('exports the tunnel types', () => { + const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; + const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; + const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; + + expect([tunnelOptions, request, handleOptions]).toHaveLength(3); + }); +});