From e05b8226de0f4f75caddf140990038fde5ce7795 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:43:32 -0400 Subject: [PATCH 01/40] feat(core): Add managed tunnel helpers that derive the tunnel path from the DSN Browser and server SDKs can now derive the same opaque tunnel path from the DSN with `getTunnelPath`, so they agree on it without a build step. `isTunnelRequest` and `handleTunnelRequestIfMatched` let a server wrapper serve the tunnel when its client has `_managedTunnel` set, which `resolveServerTunnelOption` derives from the `tunnel` option. `allowedDsns` defaults to the client's DSN and the forward to Sentry runs with tracing suppressed. --- packages/core/src/index.ts | 10 +- packages/core/src/types/options.ts | 23 +++ packages/core/src/utils/tunnel.ts | 119 ++++++++++- packages/core/test/lib/utils/tunnel.test.ts | 217 ++++++++++++++++---- 4 files changed, 312 insertions(+), 57 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aae98061c25d..b2962368c856 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,13 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; -export { handleTunnelRequest } from './utils/tunnel'; +export { + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { convertSpanLinksForEnvelope, @@ -415,7 +421,7 @@ export type { HttpHeadersCollection, ResolvedDataCollection, } from './types/datacollection'; -export type { ClientOptions, CoreOptions as Options } from './types/options'; +export type { ClientOptions, CoreOptions as Options, TunnelOptions } from './types/options'; export type { Package } from './types/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics'; export type { diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 8ea3944f1613..ee3ded025cfa 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -373,6 +373,14 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; +/** Options for the managed tunnel, see `tunnel`. */ +export interface TunnelOptions { + /** + * Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN + * here when the browser uses a different one. + */ + allowedDsns?: string[]; + + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ + basePath?: string; + + /** Browser only. Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ + trailingSlash?: boolean; +} + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 4831d6f48ea8..6b50d3b6fde4 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,6 +1,10 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { getClient } from '../currentScopes'; +import { suppressTracing } from '../tracing/trace'; +import type { DsnLike } from '../types/dsn'; +import type { TunnelOptions } from '../types/options'; import { debug } from './debug-logger'; -import { makeDsn } from './dsn'; +import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; // Bounds the memory of each request. SDK envelopes stay far below it. @@ -9,8 +13,67 @@ export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** Pre-parsed array of allowed DSN strings */ - allowedDsns: Array; + /** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */ + allowedDsns?: Array; +} + +/** + * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own + * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. + */ +export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): { + tunnel: string | undefined; + _managedTunnel?: TunnelOptions; +} { + if (typeof tunnel === 'string' || !tunnel) { + return { tunnel }; + } + + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; +} + +/** + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. + * + * Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it + * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. + */ +export function getTunnelPath(dsn: DsnLike): string | undefined { + const components = makeDsn(dsn); + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; +} + +/** + * Returns true if the request targets the managed tunnel. + * + * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its + * `init` was called with `tunnel: true`. + */ +export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean { + if (method !== 'POST') { + return false; + } + + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return false; + } + + const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, ''); + return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path); +} + +/** + * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller + * can hand the request to the app. + */ +export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { + if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) { + return undefined; + } + + const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + return handleTunnelRequest({ request, allowedDsns }); } /** @@ -22,7 +85,8 @@ export interface HandleTunnelRequestOptions { * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request, allowedDsns } = options; + const { request } = options; + const allowedDsns = resolveAllowedDsns(options.allowedDsns); if (allowedDsns.length === 0) { return new Response('Tunnel not configured', { status: 500 }); @@ -74,15 +138,48 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); try { - return await fetch(sentryIngestUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }); + // The forward is SDK plumbing, not app work, so it must not show up as a span. + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body, + }), + ); } catch (error) { debug.error('Sentry tunnel: failed to forward envelope', error); return new Response('Failed to forward envelope to Sentry', { status: 500 }); } } + +function resolveAllowedDsns(allowedDsns: Array | undefined): Array { + if (allowedDsns) { + return allowedDsns; + } + + const dsn = getClient()?.getDsn(); + return dsn ? [dsnToString(dsn)] : []; +} + +function stripBasePath(pathname: string, basePath: string | undefined): string { + const base = basePath?.replace(/\/$/, ''); + if (!base) { + return pathname; + } + + return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname; +} + +/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */ +function fnv1a(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + // eslint-disable-next-line no-bitwise + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + // eslint-disable-next-line no-bitwise + return hash >>> 0; +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 7e56a5badfca..8c091845dcb2 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,20 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { getCurrentScope, setCurrentClient } from '../../../src'; +import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel'; +import { + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from '../../../src/utils/tunnel'; +import type { TunnelOptions } from '../../../src/types/options'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record, contentLength?: number): Request { +function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength; - return new Request('http://localhost/tunnel', { - method: 'POST', - body, - headers: { 'content-length': String(contentLength ?? byteLength) }, - }); + return new Request(url, { method: 'POST', body }); } describe('handleTunnelRequest', () => { @@ -27,6 +32,7 @@ describe('handleTunnelRequest', () => { afterEach(() => { vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); }); it('forwards the envelope to Sentry and returns the upstream response', async () => { @@ -143,47 +149,170 @@ describe('handleTunnelRequest', () => { expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); - describe('body size limit', () => { - it('forwards the envelope when Content-Length equals the limit', async () => { - fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + it('forwards with tracing suppressed so the forward does not create a span', async () => { + let suppressed: boolean | undefined; + fetchMock.mockImplementationOnce(() => { + suppressed = isTracingSuppressed(); + return Promise.resolve(new Response('ok')); + }); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE), - allowedDsns: [TEST_DSN], - }); + await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); - expect(result.status).toBe(200); - }); + expect(suppressed).toBe(true); + }); - it('returns 413 when Content-Length is above the limit', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1), - allowedDsns: [TEST_DSN], - }); + it('defaults allowedDsns to the DSN of the active client', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValueOnce(new Response('ok')); - expect(result.status).toBe(413); - expect(await result.text()).toBe('Envelope too large'); - expect(fetchMock).not.toHaveBeenCalled(); - }); + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns 500 when allowedDsns is omitted and there is no client', async () => { + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelPath', () => { + it('returns a short opaque path that does not leak the DSN', () => { + const path = getTunnelPath(TEST_DSN); + + expect(path).toMatch(/^\/[0-9a-z]{1,7}$/); + expect(path).not.toContain('sentry'); + }); + + it('is stable for the same DSN and differs between DSNs', () => { + expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); + }); + + it('ignores DSN formatting differences so browser and server derive the same path', () => { + expect(getTunnelPath('https://public:secret@dsn.ingest.sentry.io/1337')).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(makeDsn(TEST_DSN)!)).toBe(getTunnelPath(TEST_DSN)); + }); + + it('returns undefined for an invalid DSN', () => { + expect(getTunnelPath('not-a-dsn')).toBeUndefined(); + }); +}); + +describe('isTunnelRequest', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('matches a POST to the tunnel path of an allowed DSN', () => { + expect(isTunnelRequest('POST', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest('POST', `${tunnelPath}/`, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match other methods, other paths, or path prefixes', () => { + expect(isTunnelRequest('GET', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest('POST', '/somewhere', { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest('POST', `${tunnelPath}x`, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('does not match the tunnel path of a DSN that is not allowed', () => { + const otherDsn = 'https://other@example.com/9999'; + + expect(isTunnelRequest('POST', getTunnelPath(otherDsn)!, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('uses the managed tunnel config of the active client when no options are passed', () => { + setTestClient(TEST_DSN); + expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + + setTestClient(TEST_DSN, {}); + expect(isTunnelRequest('POST', tunnelPath)).toBe(true); + }); + + it('matches nothing without a client', () => { + expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + expect(isTunnelRequest('POST', tunnelPath, {})).toBe(false); + }); + + it('matches under a base path', () => { + expect(isTunnelRequest('POST', `/docs${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs/' })).toBe(true); + expect(isTunnelRequest('POST', `/docsx${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe(false); + }); +}); + +describe('handleTunnelRequestIfMatched', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('forwards a matching request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); + + const result = await handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] }); - it('returns 411 when the request has no Content-Length', async () => { - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`)); - controller.close(); - }, - }); - // Node.js requires `duplex` for a stream body, but the DOM types do not have it yet. - const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' }; - - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', init), - allowedDsns: [TEST_DSN], - }); - - expect(result.status).toBe(411); - expect(await result.text()).toBe('Length required'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('forwards using the allowedDsns of the active client managed tunnel config', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: browserDsn }, `http://localhost${getTunnelPath(browserDsn)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(browserDsn)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns undefined without reading the body when the request does not match', () => { + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); + + expect(handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] })).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('resolveServerTunnelOption', () => { + it('keeps a URL as the transport tunnel', () => { + expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); + }); + + it('enables the managed tunnel for `true` and options, and leaves the transport untunneled', () => { + expect(resolveServerTunnelOption(true)).toEqual({ tunnel: undefined, _managedTunnel: {} }); + expect(resolveServerTunnelOption({ allowedDsns: [TEST_DSN] })).toEqual({ + tunnel: undefined, + _managedTunnel: { allowedDsns: [TEST_DSN] }, }); }); + + it('leaves everything off when unset', () => { + expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); + }); }); + +function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { + setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} From 3efd40c0e19f23d818c10e64a372315d8836ef26 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:37:01 -0400 Subject: [PATCH 02/40] ref(core): Pass the whole request to the tunnel matcher and expose `getTunnelPaths` Callers now hand `isTunnelRequest` a request-like object, so the matching rule can look at more of the request later without changing every framework. `getTunnelPaths` covers frameworks that can only register routes. --- packages/core/src/index.ts | 3 +- packages/core/src/utils/tunnel.ts | 44 ++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 76 +++++++++++++++++---- 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2962368c856..f753236a441a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,9 +101,10 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; -export type { HandleTunnelRequestOptions } from './utils/tunnel'; +export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { getTunnelPath, + getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 6b50d3b6fde4..10f113a1b2e6 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -6,6 +6,7 @@ import type { TunnelOptions } from '../types/options'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; +import { parseUrl } from './url'; // Bounds the memory of each request. SDK envelopes stay far below it. export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; @@ -40,17 +41,46 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions */ export function getTunnelPath(dsn: DsnLike): string | undefined { const components = makeDsn(dsn); + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; } +/** + * The parts of an incoming request the tunnel decision may look at. A fetch `Request` satisfies it, and Node SDKs pass + * an `IncomingMessage` directly. Callers always pass the whole request so the decision can change without touching them. + */ +export interface TunnelRequestLike { + method?: string; + /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ + url?: string; + headers?: Headers | Record; +} + +/** + * Returns the tunnel paths the server accepts, relative to its base path. Frameworks that can only register routes + * use these, everything else should call {@link isTunnelRequest} or {@link handleTunnelRequestIfMatched}. + * + * Without `options`, this uses the managed tunnel config of the active client. + */ +export function getTunnelPaths(options?: TunnelOptions): string[] { + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return []; + } + + return resolveAllowedDsns(tunnelOptions.allowedDsns) + .map(dsn => getTunnelPath(dsn)) + .filter((path): path is string => !!path); +} + /** * Returns true if the request targets the managed tunnel. * * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its * `init` was called with `tunnel: true`. */ -export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean { - if (method !== 'POST') { +export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOptions): boolean { + if (request.method !== 'POST' || !request.url) { return false; } @@ -59,8 +89,9 @@ export function isTunnelRequest(method: string, pathname: string, options?: Tunn return false; } - const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, ''); - return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath).replace(/\/$/, ''); + + return getTunnelPaths(tunnelOptions).includes(path); } /** @@ -68,11 +99,12 @@ export function isTunnelRequest(method: string, pathname: string, options?: Tunn * can hand the request to the app. */ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { - if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) { + if (!isTunnelRequest(request, options)) { return undefined; } const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + return handleTunnelRequest({ request, allowedDsns }); } @@ -160,6 +192,7 @@ function resolveAllowedDsns(allowedDsns: Array | undefined): Array>> 0; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 8c091845dcb2..b365206d0722 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -6,6 +6,7 @@ import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { getTunnelPath, + getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, @@ -19,6 +20,7 @@ const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); + return new Request(url, { method: 'POST', body }); } @@ -153,6 +155,7 @@ describe('handleTunnelRequest', () => { let suppressed: boolean | undefined; fetchMock.mockImplementationOnce(() => { suppressed = isTracingSuppressed(); + return Promise.resolve(new Response('ok')); }); @@ -213,38 +216,87 @@ describe('isTunnelRequest', () => { }); it('matches a POST to the tunnel path of an allowed DSN', () => { - expect(isTunnelRequest('POST', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(true); - expect(isTunnelRequest('POST', `${tunnelPath}/`, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('does not match other methods, other paths, or path prefixes', () => { - expect(isTunnelRequest('GET', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest('POST', '/somewhere', { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest('POST', `${tunnelPath}x`, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('does not match the tunnel path of a DSN that is not allowed', () => { const otherDsn = 'https://other@example.com/9999'; - expect(isTunnelRequest('POST', getTunnelPath(otherDsn)!, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('uses the managed tunnel config of the active client when no options are passed', () => { setTestClient(TEST_DSN); - expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); setTestClient(TEST_DSN, {}); - expect(isTunnelRequest('POST', tunnelPath)).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); }); it('matches nothing without a client', () => { - expect(isTunnelRequest('POST', tunnelPath)).toBe(false); - expect(isTunnelRequest('POST', tunnelPath, {})).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, {})).toBe(false); }); it('matches under a base path', () => { - expect(isTunnelRequest('POST', `/docs${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs/' })).toBe(true); - expect(isTunnelRequest('POST', `/docsx${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe(false); + expect( + isTunnelRequest({ method: 'POST', url: `/docs${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs/' }), + ).toBe(true); + expect( + isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + }); +}); + +describe('isTunnelRequest request shapes', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + it('accepts a fetch Request with an absolute URL and query', () => { + const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); + + expect(isTunnelRequest(request, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('accepts a Node IncomingMessage-like object', () => { + const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; + + expect(isTunnelRequest(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match without a URL', () => { + expect(isTunnelRequest({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); +}); + +describe('getTunnelPaths', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('returns one path per allowed DSN and skips invalid ones', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect(getTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] })).toEqual([ + getTunnelPath(TEST_DSN), + getTunnelPath(otherDsn), + ]); + }); + + it('uses the managed tunnel config of the active client', () => { + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN); + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN, {}); + expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); }); }); From ce011e51b03d15116b96169d7ff962bb813d61d2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:44:30 -0400 Subject: [PATCH 03/40] perf(core): Cache the tunnel path per DSN Servers derive the tunnel paths on every POST, so each DSN is now parsed and hashed once. An invalid DSN in `allowedDsns` also logs once instead of on every request. --- packages/core/src/utils/tunnel.ts | 21 +++++++++++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 10 ++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 10f113a1b2e6..eb16dc82a51a 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -18,6 +18,10 @@ export interface HandleTunnelRequestOptions { allowedDsns?: Array; } +// Servers derive the paths on every POST, so parse and hash each DSN once. This also keeps an invalid DSN in +// `allowedDsns` from logging on every request. Keys come from config, never from requests, so the map stays small. +const tunnelPathCache = new Map(); + /** * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. @@ -40,9 +44,17 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. */ export function getTunnelPath(dsn: DsnLike): string | undefined { + if (typeof dsn === 'string' && tunnelPathCache.has(dsn)) { + return tunnelPathCache.get(dsn); + } + const components = makeDsn(dsn); + const path = components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + if (typeof dsn === 'string') { + tunnelPathCache.set(dsn, path); + } - return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + return path; } /** @@ -205,7 +217,12 @@ function stripBasePath(pathname: string, basePath: string | undefined): string { return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname; } -/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */ +/** + * 32-bit FNV-1a, because the browser needs the path synchronously in `init` (`crypto.subtle.digest` is async) and it + * runs in every runtime without dependencies. It needn't be cryptographic since the DSN is public. + * + * Browser and server SDKs compute the path separately, so changing the algorithm breaks mixed SDK versions. + */ function fnv1a(input: string): number { let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index b365206d0722..735a97f1730b 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -206,6 +206,16 @@ describe('getTunnelPath', () => { it('returns undefined for an invalid DSN', () => { expect(getTunnelPath('not-a-dsn')).toBeUndefined(); }); + + it('parses each DSN string once, so an invalid DSN logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + getTunnelPath('also-not-a-dsn'); + getTunnelPath('also-not-a-dsn'); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); }); describe('isTunnelRequest', () => { From 1eb7e9a3f47900977ce8572cac24db02f8b2621f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:11:43 -0400 Subject: [PATCH 04/40] feat(core): Serve a fixed managed tunnel path from a relative `tunnel` on the server A relative `tunnel` can't be a transport URL on the server, so it now names the path the server serves, and `TunnelOptions.path` overrides the DSN-derived path. `tunnel: '/monitor'` on both sides then gives a fixed path the same way `tunnel: true` gives the DSN-derived one. --- packages/core/src/types/options.ts | 3 +++ packages/core/src/utils/tunnel.ts | 11 ++++++++++- packages/core/test/lib/utils/tunnel.test.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index ee3ded025cfa..00d60c38cb80 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -661,6 +661,9 @@ export interface TunnelOptions { */ allowedDsns?: string[]; + /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ + path?: string; + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ basePath?: string; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index eb16dc82a51a..92eee9b7d993 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -30,10 +30,15 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions tunnel: string | undefined; _managedTunnel?: TunnelOptions; } { - if (typeof tunnel === 'string' || !tunnel) { + if (!tunnel) { return { tunnel }; } + if (typeof tunnel === 'string') { + // A relative path can't be a transport URL on the server, so it names the path to serve instead. + return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; + } + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; } @@ -80,6 +85,10 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { return []; } + if (tunnelOptions.path) { + return [tunnelOptions.path]; + } + return resolveAllowedDsns(tunnelOptions.allowedDsns) .map(dsn => getTunnelPath(dsn)) .filter((path): path is string => !!path); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 735a97f1730b..f8fb2275e365 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -299,6 +299,13 @@ describe('getTunnelPaths', () => { ]); }); + it('returns a fixed path instead of the DSN-derived ones', () => { + expect(getTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] })).toEqual(['/monitor']); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + true, + ); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -370,6 +377,10 @@ describe('resolveServerTunnelOption', () => { }); }); + it('serves a relative path instead of using it as the transport tunnel', () => { + expect(resolveServerTunnelOption('/monitor')).toEqual({ tunnel: undefined, _managedTunnel: { path: '/monitor' } }); + }); + it('leaves everything off when unset', () => { expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); }); From a0e794c4cb4a6bd711fe5e6b0eea7a2108b5109e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:47:06 -0400 Subject: [PATCH 05/40] feat(core): Drop the server spans of managed tunnel requests Server SDKs start the incoming request span before a framework hook gets to answer a tunnel request, so clients with the managed tunnel enabled now add the tunnel paths to `ignoreSpans`. --- packages/core/src/client.ts | 6 +++ packages/core/src/types/options.ts | 2 +- packages/core/src/utils/tunnel.ts | 38 +++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 57 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c61b3975d41b..b87a294792ac 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,6 +41,7 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; +import { getTunnelIgnoreSpanFilter } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -271,6 +272,11 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } + const tunnelSpanFilter = options._managedTunnel && getTunnelIgnoreSpanFilter(options._managedTunnel, this._dsn); + if (tunnelSpanFilter) { + this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), tunnelSpanFilter]; + } + const { beforeSendSpan, traceLifecycle } = this._options; // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, // so a mismatch means it is silently never called. diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 00d60c38cb80..e5b7be5d96dc 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -96,7 +96,7 @@ export type IgnoreSpanAttributeValue = string | boolean | number | string[] | bo * At least one of the properties (`name`, `op`, or `attributes`) must be set. * If multiple are set, all must match for the span to be ignored. */ -type IgnoreSpanFilter = +export type IgnoreSpanFilter = | { /** * Spans with a name matching this pattern will be ignored. diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 92eee9b7d993..46bb84f625d5 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,8 +1,10 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; -import type { DsnLike } from '../types/dsn'; -import type { TunnelOptions } from '../types/options'; +import { URL_PATH } from '@sentry/conventions/attributes'; +import type { DsnComponents, DsnLike } from '../types/dsn'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; @@ -85,13 +87,27 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { return []; } - if (tunnelOptions.path) { - return [tunnelOptions.path]; + return tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns)); +} + +/** + * An `ignoreSpans` filter for the server spans of tunnel requests. Server SDKs start the span before a framework + * hook gets to answer the request, so clients add this whenever the managed tunnel is enabled. + */ +export function getTunnelIgnoreSpanFilter( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter | undefined { + const allowedDsns = tunnelOptions.allowedDsns ?? (dsn ? [dsnToString(dsn)] : []); + const paths = tunnelPathsFor(tunnelOptions, allowedDsns).map(escapeStringForRegex); + if (!paths.length) { + return undefined; } - return resolveAllowedDsns(tunnelOptions.allowedDsns) - .map(dsn => getTunnelPath(dsn)) - .filter((path): path is string => !!path); + const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + return { attributes: { [URL_PATH]: new RegExp(`^(?:${basePath})?(?:${paths.join('|')})/?$`) } }; } /** @@ -243,3 +259,11 @@ function fnv1a(input: string): number { // eslint-disable-next-line no-bitwise return hash >>> 0; } + +function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { + if (tunnelOptions.path) { + return [tunnelOptions.path]; + } + + return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index f8fb2275e365..9a9238a9499a 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -6,6 +6,7 @@ import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { getTunnelPath, + getTunnelIgnoreSpanFilter, getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, @@ -364,6 +365,62 @@ describe('handleTunnelRequestIfMatched', () => { }); }); +describe('getTunnelIgnoreSpanFilter', () => { + function urlPathMatcher(filter: ReturnType): RegExp { + return (filter as { attributes: { 'url.path': RegExp } }).attributes['url.path']; + } + + it("matches the tunnel path of the client's DSN, with or without a trailing slash", () => { + const matcher = urlPathMatcher(getTunnelIgnoreSpanFilter({}, makeDsn(TEST_DSN))); + const tunnelPath = getTunnelPath(TEST_DSN)!; + + expect(matcher.test(tunnelPath)).toBe(true); + expect(matcher.test(`${tunnelPath}/`)).toBe(true); + expect(matcher.test(`${tunnelPath}x`)).toBe(false); + expect(matcher.test('/api/users')).toBe(false); + }); + + it('follows allowedDsns, a fixed path and the base path', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(otherDsn)!, + ), + ).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( + `/app${getTunnelPath(TEST_DSN)}`, + ), + ).toBe(true); + }); + + it('returns nothing when there is no path to match', () => { + expect(getTunnelIgnoreSpanFilter({}, undefined)).toBeUndefined(); + }); +}); + +describe('client with the managed tunnel enabled', () => { + it('ignores the server spans of tunnel requests and keeps user filters', () => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), + ); + const ignoreSpans = client.getOptions().ignoreSpans!; + + expect(ignoreSpans[0]).toBe('user-filter'); + expect( + (ignoreSpans[1] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test(getTunnelPath(TEST_DSN)!), + ).toBe(true); + }); + + it('leaves ignoreSpans alone without the managed tunnel', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN })); + + expect(client.getOptions().ignoreSpans).toBeUndefined(); + }); +}); + describe('resolveServerTunnelOption', () => { it('keeps a URL as the transport tunnel', () => { expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); From 1ed892e94298555c83fe61a34d06385a425a4a8c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 06/40] fix(core): Skip fetch instrumentation while tracing is suppressed --- packages/core/src/integrations/fetch.ts | 4 +++- .../core/test/lib/integrations/fetch.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index 5cf7a0489719..dd5fe7e16ced 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -5,6 +5,7 @@ import { getClient } from '../currentScopes'; import { instrumentFetchRequest } from '../fetch'; import { defineIntegration } from '../integration'; import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import { isTracingSuppressed } from '../tracing/trace'; import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; import type { HandlerDataFetch } from '../types/instrument'; import type { Integration, IntegrationFn } from '../types/integration'; @@ -92,7 +93,8 @@ export function createFetchIntegration({ return; } - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + // Matches Node's http and undici instrumentation, so SDK-internal requests (e.g. a tunnel forward) leave no breadcrumb. + if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index 0cdd7bf223e4..d309fcf00321 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -4,6 +4,7 @@ import * as currentScopesModule from '../../../src/currentScopes'; import * as fetchModule from '../../../src/fetch'; import { createFetchIntegration } from '../../../src/integrations/fetch'; import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import { suppressTracing } from '../../../src/tracing/trace'; import type { HandlerDataFetch } from '../../../src/types/instrument'; import type { Integration } from '../../../src/types/integration'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -136,6 +137,23 @@ describe('createFetchIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); + it('does not instrument requests made while tracing is suppressed', () => { + const handler = setupIntegration(fetchIntegration(), client); + + suppressTracing(() => + handler({ + fetchData: { url: 'https://o1.ingest.us.sentry.io/api/2/envelope/', method: 'POST' }, + args: ['https://o1.ingest.us.sentry.io/api/2/envelope/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }), + ); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + it('applies the `shouldCreateSpanForRequest` option', () => { const handler = setupIntegration( fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), From 4e63500083385978d6fb5d1c63efcbe030217494 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 07/40] feat(core): Check the tunnel DSN before reading the envelope body --- packages/core/src/utils/tunnel.ts | 101 +++++++++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 74 +++++++++++++- 2 files changed, 159 insertions(+), 16 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 46bb84f625d5..2b7df2e80bbc 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -161,20 +161,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Tunnel not configured', { status: 500 }); } - // The HTTP server ends the body at `Content-Length`, so the header bounds what `arrayBuffer()` reads. - const contentLength = Number(request.headers.get('content-length')); - if (!contentLength) { - return new Response('Length required', { status: 411 }); - } - if (contentLength > MAX_TUNNEL_BODY_SIZE) { - return new Response('Envelope too large', { status: 413 }); + const reader = request.body?.getReader(); + const head = reader && (await readEnvelopeHeaderLine(reader)); + if (!reader || !head) { + return new Response('Invalid envelope', { status: 400 }); } - const body = new Uint8Array(await request.arrayBuffer()); - let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(body); + [envelopeHeader] = parseEnvelope(head.line); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -211,11 +206,11 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return await suppressTracing(() => fetch(sentryIngestUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }), + headers: forwardHeaders(request), + // The body streams through without being buffered, so its size is left for Sentry to judge. + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), ); } catch (error) { debug.error('Sentry tunnel: failed to forward envelope', error); @@ -223,6 +218,82 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): } } +/** + * The header is a small JSON object, so a line longer than this is not an envelope. + */ +const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; + +/** + * Reads the request body only up to the end of the envelope header line. + * Returns the line and every byte read so far, or `undefined` when no header line fits the limit. + */ +async function readEnvelopeHeaderLine( + reader: ReadableStreamDefaultReader, +): Promise<{ line: Uint8Array; read: Uint8Array } | undefined> { + let read: Uint8Array = new Uint8Array(0); + + while (read.length <= MAX_ENVELOPE_HEADER_BYTES) { + const { done, value } = await reader.read(); + if (done) { + return read.length ? { line: read, read } : undefined; + } + + const searchFrom = read.length; + read = concatBytes(read, value); + + const newline = read.indexOf(0x0a, searchFrom); + if (newline !== -1) { + return { line: read.subarray(0, newline), read }; + } + } + + await reader.cancel(); + + return undefined; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length); + merged.set(a); + merged.set(b, a.length); + + return merged; +} + +function prependToStream( + prefix: Uint8Array, + reader: ReadableStreamDefaultReader, +): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(prefix); + }, + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +function forwardHeaders(request: Request): Record { + const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; + // Keeps the upload fixed-length where the runtime allows it (Node, Bun), instead of chunked. + const contentLength = request.headers.get('content-length'); + if (contentLength) { + headers['Content-Length'] = contentLength; + } + + return headers; +} + function resolveAllowedDsns(allowedDsns: Array | undefined): Array { if (allowedDsns) { return allowedDsns; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 9a9238a9499a..b40b65dd20f6 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -25,6 +25,25 @@ function makeEnvelopeRequest(envelopeHeader: Record, url = 'htt return new Request(url, { method: 'POST', body }); } +/** + * A request whose body starts with `head` and then never ends, so buffering it would hang the test. + */ +function makeEndlessRequest(head: string): Request { + const filler = new Uint8Array(16 * 1024).fill(0x61); + let sentHead = false; + + return new Request('http://localhost/tunnel', { + method: 'POST', + duplex: 'half', + body: new ReadableStream({ + pull(controller) { + controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); + sentHead = true; + }, + }), + } as RequestInit); +} + describe('handleTunnelRequest', () => { let fetchMock: ReturnType; @@ -52,11 +71,64 @@ describe('handleTunnelRequest', () => { expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); - expect(init.body).toBeInstanceOf(Uint8Array); + expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); + + expect(result).toBe(upstreamResponse); + }); + + it('forwards the content length of the incoming request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const body = `${JSON.stringify({ dsn: TEST_DSN })}\n`; + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': String(body.length) }, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', 'Content-Length': String(body.length) }, + }), + ); + }); + + 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], + }); + + expect(result.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { + const upstreamResponse = new Response('ok'); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + allowedDsns: [TEST_DSN], + }); expect(result).toBe(upstreamResponse); }); + it('returns 400 when the envelope header line exceeds the limit', async () => { + const result = await handleTunnelRequest({ + request: 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 }), From 3dda6fa63c2358131aeb7461e3432e250a5db38b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:25:49 -0400 Subject: [PATCH 08/40] fix(core): Cancel the body of a rejected tunnel request --- packages/core/src/utils/tunnel.ts | 49 +++++++++++++-------- packages/core/test/lib/utils/tunnel.test.ts | 19 +++++++- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 2b7df2e80bbc..efee1b4828d0 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -167,9 +167,38 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid envelope', { status: 400 }); } + const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); + if (typeof sentryIngestUrl !== 'string') { + // Nothing past the header gets forwarded, so the runtime can stop receiving the body. + reader.cancel().catch(() => undefined); + + return sentryIngestUrl; + } + + try { + // The forward is SDK plumbing, not app work, so it must not show up as a span. + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: forwardHeaders(request), + // The body streams through without being buffered, so its size is left for Sentry to judge. + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), + ); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } +} + +/** + * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. + */ +function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(head.line); + [envelopeHeader] = parseEnvelope(headerLine); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -199,23 +228,7 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid DSN', { status: 403 }); } - const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); - - try { - // The forward is SDK plumbing, not app work, so it must not show up as a span. - return await suppressTracing(() => - fetch(sentryIngestUrl, { - method: 'POST', - headers: forwardHeaders(request), - // The body streams through without being buffered, so its size is left for Sentry to judge. - body: prependToStream(head.read, reader), - duplex: 'half', - } as RequestInit), - ); - } catch (error) { - debug.error('Sentry tunnel: failed to forward envelope', error); - return new Response('Failed to forward envelope to Sentry', { status: 500 }); - } + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); } /** diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index b40b65dd20f6..b99d72fa2fbd 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -28,7 +28,7 @@ function makeEnvelopeRequest(envelopeHeader: Record, url = 'htt /** * A request whose body starts with `head` and then never ends, so buffering it would hang the test. */ -function makeEndlessRequest(head: string): Request { +function makeEndlessRequest(head: string, onCancel?: () => void): Request { const filler = new Uint8Array(16 * 1024).fill(0x61); let sentHead = false; @@ -40,6 +40,7 @@ function makeEndlessRequest(head: string): Request { controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); sentHead = true; }, + cancel: onCancel, }), } as RequestInit); } @@ -107,6 +108,17 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + 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], + }); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { const upstreamResponse = new Response('ok'); fetchMock.mockResolvedValueOnce(upstreamResponse); @@ -266,6 +278,11 @@ describe('getTunnelPath', () => { expect(path).not.toContain('sentry'); }); + // Browser and server SDKs of different versions must derive the same path, so the hash must never change. + it('derives a fixed path for a known DSN', () => { + expect(getTunnelPath(TEST_DSN)).toBe('/fdv680'); + }); + it('is stable for the same DSN and differs between DSNs', () => { expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); From 6b21417285c6c1b0fdeaa72d1774921d873e4715 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:28:11 -0400 Subject: [PATCH 09/40] feat(core): Forward the client IP of tunnel requests to Sentry --- packages/core/src/utils/tunnel.ts | 10 +++++++++ packages/core/test/lib/utils/tunnel.test.ts | 24 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index efee1b4828d0..a1c6f5905bad 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -304,6 +304,16 @@ function forwardHeaders(request: Request): Record { headers['Content-Length'] = contentLength; } + // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the + // visitor's IP in these headers, in the order Relay itself reads them. + const clientIp = + request.headers.get('cf-connecting-ip') || + request.headers.get('x-vercel-forwarded-for') || + request.headers.get('x-forwarded-for'); + if (clientIp) { + headers['X-Sentry-Forwarded-For'] = clientIp; + } + return headers; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index b99d72fa2fbd..602fe3fb0fe0 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -98,6 +98,30 @@ describe('handleTunnelRequest', () => { ); }); + it.each([ + ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], + ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], + ['x-forwarded-for', { 'x-forwarded-for': '2.2.2.2, 10.0.0.1' }, '2.2.2.2, 10.0.0.1'], + ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + headers: incomingHeaders, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Sentry-Forwarded-For': forwardedFor }), + }), + ); + }); + 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`), From 30ff5126999dc15b1d3e778ec9455a6f11f3cbc1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:38:40 -0400 Subject: [PATCH 10/40] fix(core): Match a fixed tunnel path that ends with a slash --- packages/core/src/utils/tunnel.ts | 2 +- packages/core/test/lib/utils/tunnel.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index a1c6f5905bad..2693bb07c436 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -356,7 +356,7 @@ function fnv1a(input: string): number { function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { if (tunnelOptions.path) { - return [tunnelOptions.path]; + return [tunnelOptions.path.replace(/\/$/, '')]; } return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 602fe3fb0fe0..d20fed94a5aa 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -420,6 +420,11 @@ describe('getTunnelPaths', () => { ); }); + it('matches a fixed path that ends with a slash', () => { + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -502,6 +507,7 @@ describe('getTunnelIgnoreSpanFilter', () => { ), ).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); expect( urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( `/app${getTunnelPath(TEST_DSN)}`, From 8bd63583c73752945af2a66189b8d2cb93646324 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 09:57:47 -0400 Subject: [PATCH 11/40] fix(core): Only answer the managed tunnel under its base path --- packages/core/src/utils/tunnel.ts | 13 ++++++++----- packages/core/test/lib/utils/tunnel.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 2693bb07c436..48083f05d7f8 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -107,7 +107,7 @@ export function getTunnelIgnoreSpanFilter( const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return { attributes: { [URL_PATH]: new RegExp(`^(?:${basePath})?(?:${paths.join('|')})/?$`) } }; + return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } /** @@ -126,9 +126,9 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOpti return false; } - const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath).replace(/\/$/, ''); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); - return getTunnelPaths(tunnelOptions).includes(path); + return path !== undefined && getTunnelPaths(tunnelOptions).includes(path.replace(/\/$/, '')); } /** @@ -327,13 +327,16 @@ function resolveAllowedDsns(allowedDsns: Array | undefined): Array { isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), ).toBe(false); }); + + it('matches nothing outside the base path', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe( + false, + ); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe(false); + }); }); describe('isTunnelRequest request shapes', () => { @@ -513,6 +520,9 @@ describe('getTunnelIgnoreSpanFilter', () => { `/app${getTunnelPath(TEST_DSN)}`, ), ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN))).test(getTunnelPath(TEST_DSN)!), + ).toBe(false); }); it('returns nothing when there is no path to match', () => { From d0fd95621f2067e4a9aa1f934cb65635609898b4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 10:34:20 -0400 Subject: [PATCH 12/40] feat(core): Always accept the server's own DSN in the managed tunnel --- packages/core/src/types/options.ts | 2 +- packages/core/src/utils/tunnel.ts | 27 +++++++++++-------- packages/core/test/lib/utils/tunnel.test.ts | 29 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index e5b7be5d96dc..d2896dd3979b 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -656,7 +656,7 @@ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; /** Options for the managed tunnel, see `tunnel`. */ export interface TunnelOptions { /** - * Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN + * Server only. DSNs whose envelopes the tunnel forwards, in addition to the server's own DSN. List the browser DSN * here when the browser uses a different one. */ allowedDsns?: string[]; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 48083f05d7f8..df2debe0f8e1 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -16,7 +16,7 @@ export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */ + /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ allowedDsns?: Array; } @@ -98,8 +98,9 @@ export function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { - const allowedDsns = tunnelOptions.allowedDsns ?? (dsn ? [dsnToString(dsn)] : []); - const paths = tunnelPathsFor(tunnelOptions, allowedDsns).map(escapeStringForRegex); + const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( + escapeStringForRegex, + ); if (!paths.length) { return undefined; } @@ -317,14 +318,17 @@ function forwardHeaders(request: Request): Record { return headers; } -function resolveAllowedDsns(allowedDsns: Array | undefined): Array { - if (allowedDsns) { - return allowedDsns; - } - - const dsn = getClient()?.getDsn(); +/** + * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock + * out a browser that shares it. It goes last, because React Router can serve only the first path. + */ +function resolveAllowedDsns( + allowedDsns: Array | undefined, + ownDsn: DsnComponents | undefined = getClient()?.getDsn(), +): Array { + const dsns = [...(allowedDsns ?? []), ...(ownDsn ? [dsnToString(ownDsn)] : [])]; - return dsn ? [dsnToString(dsn)] : []; + return [...new Set(dsns)]; } /** @@ -362,5 +366,6 @@ function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array return [tunnelOptions.path.replace(/\/$/, '')]; } - return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); + // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + return [...new Set(allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path))]; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 846a1c208696..604801391e9e 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -273,6 +273,23 @@ describe('handleTunnelRequest', () => { expect(suppressed).toBe(true); }); + it('accepts the DSN of the active client on top of allowedDsns', async () => { + 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' }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + + expect(ownDsnResult.status).toBe(200); + expect(unlistedDsnResult.status).toBe(403); + }); + it('defaults allowedDsns to the DSN of the active client', async () => { setTestClient(TEST_DSN); fetchMock.mockResolvedValueOnce(new Response('ok')); @@ -432,6 +449,13 @@ describe('getTunnelPaths', () => { expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); }); + it('serves the path of the active client DSN on top of allowedDsns, once', () => { + const browserDsn = 'https://browser@o2.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn, TEST_DSN] }); + + expect(getTunnelPaths()).toEqual([getTunnelPath(browserDsn), getTunnelPath(TEST_DSN)]); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -513,6 +537,11 @@ describe('getTunnelIgnoreSpanFilter', () => { getTunnelPath(otherDsn)!, ), ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(TEST_DSN)!, + ), + ).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); expect( From 3ee72699a226f38c4b3c8b795713bfde4d2c2df2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:47:06 -0400 Subject: [PATCH 13/40] feat(core): Add `applyTunnelDefaults` so framework SDKs fill in the base path the same way --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 20 ++++++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 23 +++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f753236a441a..4a15ed3fdf32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,6 +103,7 @@ export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { + applyTunnelDefaults, getTunnelPath, getTunnelPaths, handleTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index df2debe0f8e1..97f7c2f6c1ab 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -44,6 +44,26 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; } +/** + * Fills in the defaults a framework SDK knows about its app, like the base path, for a managed tunnel. Options the + * user set win. A relative `tunnel` path is a managed tunnel with that `path`, so it gets the defaults too, while a + * URL and an unset `tunnel` are returned as they are. + */ +export function applyTunnelDefaults( + tunnel: string | true | TunnelOptions | undefined, + defaults: TunnelOptions | undefined, +): string | true | TunnelOptions | undefined { + if (!tunnel || !defaults || !Object.keys(defaults).length) { + return tunnel; + } + + if (typeof tunnel === 'string') { + return tunnel.startsWith('/') ? { ...defaults, path: tunnel } : tunnel; + } + + return { ...defaults, ...(tunnel === true ? {} : tunnel) }; +} + /** * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. * diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 604801391e9e..1b3f0e14c890 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -5,6 +5,7 @@ import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { + applyTunnelDefaults, getTunnelPath, getTunnelIgnoreSpanFilter, getTunnelPaths, @@ -601,6 +602,28 @@ describe('resolveServerTunnelOption', () => { }); }); +describe('applyTunnelDefaults', () => { + it('fills in the defaults for `true` and options, and lets options the user set win', () => { + expect(applyTunnelDefaults(true, { basePath: '/app' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults({ allowedDsns: [TEST_DSN] }, { basePath: '/app' })).toEqual({ + basePath: '/app', + allowedDsns: [TEST_DSN], + }); + expect(applyTunnelDefaults({ basePath: '/custom' }, { basePath: '/app' })).toEqual({ basePath: '/custom' }); + }); + + it('treats a relative path as a managed tunnel with that path', () => { + expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + }); + + it('leaves a URL, an unset tunnel and empty defaults alone', () => { + expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); + expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); + expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); + expect(applyTunnelDefaults(true, undefined)).toBe(true); + }); +}); + function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From 786c90fa8e05d2e959dc1ac27d97fcfba4ebd766 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:59:20 -0400 Subject: [PATCH 14/40] feat(core): Add `setManagedTunnelBasePath` for servers that learn the base path after `init` --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 21 ++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 38 +++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a15ed3fdf32..f8dd7cb802da 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -110,6 +110,7 @@ export { handleTunnelRequestIfMatched, isTunnelRequest, resolveServerTunnelOption, + setManagedTunnelBasePath, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 97f7c2f6c1ab..eab2264cce7e 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,4 +1,5 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -131,6 +132,26 @@ export function getTunnelIgnoreSpanFilter( return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } +/** + * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime + * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. + */ +export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel; + const normalizedBasePath = basePath?.replace(/\/+$/, ''); + if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { + return; + } + + options._managedTunnel = { ...tunnelOptions, basePath: normalizedBasePath }; + + const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); + if (spanFilter) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), spanFilter]; + } +} + /** * Returns true if the request targets the managed tunnel. * diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 1b3f0e14c890..9553a573010d 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -13,6 +13,7 @@ import { handleTunnelRequestIfMatched, isTunnelRequest, resolveServerTunnelOption, + setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; import type { TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -624,6 +625,43 @@ describe('applyTunnelDefaults', () => { }); }); +describe('setManagedTunnelBasePath', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('serves the tunnel under the base path and drops its spans there', () => { + setTestClient(TEST_DSN, {}); + const client = getCurrentScope().getClient()!; + + setManagedTunnelBasePath(client, '/app/'); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); + expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) })).toBe(false); + expect(client.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN)), + ); + }); + + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { + setTestClient(TEST_DSN, { basePath: '/custom' }); + const client = getCurrentScope().getClient()!; + setManagedTunnelBasePath(client, '/app'); + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + + setTestClient(TEST_DSN, {}); + const rootClient = getCurrentScope().getClient()!; + setManagedTunnelBasePath(rootClient, '/'); + expect(rootClient.getOptions()._managedTunnel).toEqual({}); + + setTestClient(TEST_DSN); + const offClient = getCurrentScope().getClient()!; + setManagedTunnelBasePath(offClient, '/app'); + expect(offClient.getOptions()._managedTunnel).toBeUndefined(); + }); +}); + function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From 3f76c2a60460eaa6b8503fb5e14f38bdb0f66cd1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:13:08 -0400 Subject: [PATCH 15/40] feat(core): Mark the tunnel handler's responses so the browser can tell them apart --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 20 ++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 29 ++++++++++++++++++--- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8dd7cb802da..41f56d747dd7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,6 +111,7 @@ export { isTunnelRequest, resolveServerTunnelOption, setManagedTunnelBasePath, + TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index eab2264cce7e..ded38f2c004d 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -196,6 +196,26 @@ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelO * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + return markTunnelResponse(await forwardTunnelRequest(options)); +} + +/** + * Set on every response the tunnel handler returns, so a browser can tell it apart from whatever else answers the + * tunnel path, e.g. a static host serving `index.html` for every path. + */ +export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; + +function markTunnelResponse(response: Response): Response { + const headers = new Headers(response.headers); + // `fetch` already decoded the body, so its original encoding and length no longer describe it. + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.set(TUNNEL_RESPONSE_HEADER, '1'); + + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promise { const { request } = options; const allowedDsns = resolveAllowedDsns(options.allowedDsns); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 9553a573010d..0b15befeb9fd 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -76,7 +76,30 @@ describe('handleTunnelRequest', () => { expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); - expect(result).toBe(upstreamResponse); + expect(result.status).toBe(200); + expect(await result.text()).toBe('ok'); + }); + + it("marks every response it returns as the tunnel's, so the browser can tell it from a static host", async () => { + fetchMock.mockResolvedValueOnce( + 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 rejected = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://public@other.ingest.sentry.io/1' }), + allowedDsns: [TEST_DSN], + }); + + expect(forwarded.headers.get('X-Sentry-Tunnel')).toBe('1'); + expect(forwarded.headers.get('Content-Type')).toBe('application/json'); + // `fetch` already decoded the body, so the original encoding would make the browser decode it twice. + expect(forwarded.headers.get('Content-Encoding')).toBeNull(); + expect(rejected.status).toBe(403); + expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); }); it('forwards the content length of the incoming request', async () => { @@ -154,7 +177,7 @@ describe('handleTunnelRequest', () => { allowedDsns: [TEST_DSN], }); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 400 when the envelope header line exceeds the limit', async () => { @@ -246,7 +269,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchMock.mock.calls[0]!; expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 500 when fetch throws a network error', async () => { From 8a5c355f46222b2f9f0c8d7dce3d4538156d556a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:00:11 -0400 Subject: [PATCH 16/40] fix(core): Normalize tunnel base paths with one util that doesn't backtrack --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 15 ++++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41f56d747dd7..578bc3e949b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export { handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, + normalizeTunnelBasePath, resolveServerTunnelOption, setManagedTunnelBasePath, TUNNEL_RESPONSE_HEADER, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index ded38f2c004d..271cc076db80 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -126,12 +126,21 @@ export function getTunnelIgnoreSpanFilter( return undefined; } - const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } +/** + * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. + */ +export function normalizeTunnelBasePath(basePath: string | undefined): string { + const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); + + return !path || path.startsWith('/') ? path : `/${path}`; +} + /** * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. @@ -139,7 +148,7 @@ export function getTunnelIgnoreSpanFilter( export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { const options = client?.getOptions(); const tunnelOptions = options?._managedTunnel; - const normalizedBasePath = basePath?.replace(/\/+$/, ''); + const normalizedBasePath = normalizeTunnelBasePath(basePath); if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { return; } @@ -396,7 +405,7 @@ function resolveAllowedDsns( * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. */ function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { - const base = basePath?.replace(/\/$/, ''); + const base = normalizeTunnelBasePath(basePath); if (!base) { return pathname; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 0b15befeb9fd..0594eef69187 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -12,6 +12,7 @@ import { handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, + normalizeTunnelBasePath, resolveServerTunnelOption, setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; @@ -648,6 +649,21 @@ describe('applyTunnelDefaults', () => { }); }); +describe('normalizeTunnelBasePath', () => { + it.each([ + ['/app', '/app'], + ['/app/', '/app'], + ['app', '/app'], + ['./app/', '/app'], + ['./', ''], + ['/', ''], + ['', ''], + [undefined, ''], + ])('normalizes %j to %j', (input, expected) => { + expect(normalizeTunnelBasePath(input)).toBe(expected); + }); +}); + describe('setManagedTunnelBasePath', () => { afterEach(() => { getCurrentScope().setClient(undefined); From 1d62dfbf7f8a283a992f21fe1f5581683e28409f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:07:25 -0400 Subject: [PATCH 17/40] fix(core): Leave a fixed tunnel path to the app when there is no DSN to forward to --- packages/core/src/utils/tunnel.ts | 5 +++++ packages/core/test/lib/utils/tunnel.test.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 271cc076db80..c93c6aefb24e 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -192,6 +192,11 @@ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelO } const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes + // the browser send directly, instead of losing every envelope to an error response. + if (!resolveAllowedDsns(allowedDsns).length) { + return undefined; + } return handleTunnelRequest({ request, allowedDsns }); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 0594eef69187..9b75a8da8582 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -531,6 +531,15 @@ describe('handleTunnelRequestIfMatched', () => { ); }); + it('leaves a fixed path to the app when there is no DSN to forward to', () => { + setTestClient(undefined, { path: '/monitor' }); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(handleTunnelRequestIfMatched(request, { path: '/monitor' })).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + }); + it('returns undefined without reading the body when the request does not match', () => { const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); @@ -701,6 +710,6 @@ describe('setManagedTunnelBasePath', () => { }); }); -function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { +function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From 1fd2f88e1c178b56fc546de37af5139724b8519a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:41:09 -0400 Subject: [PATCH 18/40] fix(core): Don't add the base path again to a tunnel path that already starts with it --- packages/core/src/utils/tunnel.ts | 16 +++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 23 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index c93c6aefb24e..8623524d0190 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -59,10 +59,10 @@ export function applyTunnelDefaults( } if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? { ...defaults, path: tunnel } : tunnel; + return tunnel.startsWith('/') ? withPathBelowBasePath({ ...defaults, path: tunnel }) : tunnel; } - return { ...defaults, ...(tunnel === true ? {} : tunnel) }; + return withPathBelowBasePath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); } /** @@ -153,7 +153,7 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s return; } - options._managedTunnel = { ...tunnelOptions, basePath: normalizedBasePath }; + options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); if (spanFilter) { @@ -406,6 +406,16 @@ function resolveAllowedDsns( return [...new Set(dsns)]; } +/** + * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to + * be before the SDK added the base path, would otherwise be served under the base path twice. + */ +function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { + const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); + + return path ? { ...tunnelOptions, path } : tunnelOptions; +} + /** * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. */ diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 9b75a8da8582..11357a314fac 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -650,6 +650,18 @@ describe('applyTunnelDefaults', () => { expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); }); + it('does not add the base path again to a path that already starts with it', () => { + expect(applyTunnelDefaults('/app/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + expect(applyTunnelDefaults({ path: '/app/monitor' }, { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/monitor', + }); + expect(applyTunnelDefaults('/application/monitor', { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/application/monitor', + }); + }); + it('leaves a URL, an unset tunnel and empty defaults alone', () => { expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); @@ -692,6 +704,17 @@ describe('setManagedTunnelBasePath', () => { ); }); + it('does not add the base path again to a fixed path that already starts with it', () => { + setTestClient(TEST_DSN, { path: '/app/monitor' }); + const client = getCurrentScope().getClient()!; + + setManagedTunnelBasePath(client, '/app'); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); + expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/app/app/monitor' })).toBe(false); + }); + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { setTestClient(TEST_DSN, { basePath: '/custom' }); const client = getCurrentScope().getClient()!; From dd8c6716eb553245cfc719a02dceb521bf0f2459 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:28:57 -0400 Subject: [PATCH 19/40] fix(core): Don't report a client that drops the tunnel request as an error --- packages/core/src/utils/tunnel.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 8623524d0190..ee1c4411b2ed 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -238,7 +238,9 @@ async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promis } const reader = request.body?.getReader(); - const head = reader && (await readEnvelopeHeaderLine(reader)); + // A client that drops the connection mid-upload makes the read throw. That is the client going away, not an app + // error, so it gets the same answer as any other unreadable envelope. + const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); if (!reader || !head) { return new Response('Invalid envelope', { status: 400 }); } From 6cb4e27a0021fe05ab0e2b04b6175d497806297f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:24:57 -0400 Subject: [PATCH 20/40] fix(core): Also drop tunnel spans that only carry `http.url`, like SvelteKit's root span --- packages/core/src/client.ts | 10 +++++--- packages/core/src/utils/tunnel.ts | 42 ++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b87a294792ac..621faec3dc49 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,7 +41,7 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; -import { getTunnelIgnoreSpanFilter } from './utils/tunnel'; +import { getTunnelIgnoreSpanFilters } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -272,9 +272,11 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } - const tunnelSpanFilter = options._managedTunnel && getTunnelIgnoreSpanFilter(options._managedTunnel, this._dsn); - if (tunnelSpanFilter) { - this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), tunnelSpanFilter]; + const tunnelSpanFilters = options._managedTunnel + ? getTunnelIgnoreSpanFilters(options._managedTunnel, this._dsn) + : []; + if (tunnelSpanFilters.length) { + this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), ...tunnelSpanFilters]; } const { beforeSendSpan, traceLifecycle } = this._options; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index ee1c4411b2ed..e9854b259305 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -119,6 +119,37 @@ export function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { + const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + return pathPattern ? { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } } : undefined; +} + +/** + * The `ignoreSpans` filters a client installs for tunnel requests. Besides `url.path`, they match the legacy + * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. + */ +export function getTunnelIgnoreSpanFilters( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter[] { + const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); + if (!pathPattern) { + return []; + } + + return [ + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { 'http.url': new RegExp(`^[a-z][a-z\\d+.-]*://[^/]+${pathPattern}(?:[?#].*)?$`) } }, + ]; +} + +/** + * The tunnel paths under the base path, as a regex source matching a pathname with an optional trailing slash. + */ +function getTunnelPathPattern(tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined): string | undefined { const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( escapeStringForRegex, ); @@ -128,8 +159,7 @@ export function getTunnelIgnoreSpanFilter( const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); - // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; + return `${basePath}(?:${paths.join('|')})/?`; } /** @@ -155,9 +185,9 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); - const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); - if (spanFilter) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), spanFilter]; + const spanFilters = getTunnelIgnoreSpanFilters(options._managedTunnel, client?.getDsn()); + if (spanFilters.length) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; } } @@ -397,7 +427,7 @@ function forwardHeaders(request: Request): Record { /** * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock - * out a browser that shares it. It goes last, because React Router can serve only the first path. + * out a browser that shares it. */ function resolveAllowedDsns( allowedDsns: Array | undefined, From 34a938dce953f6e8b833ff5c27d6739b4f1d282b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:40:02 -0400 Subject: [PATCH 21/40] feat(core): Add enableManagedTunnel for framework options that enable the tunnel outside init --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 578bc3e949b9..ad62826a3f51 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -104,6 +104,7 @@ export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { applyTunnelDefaults, + enableManagedTunnel, getTunnelPath, getTunnelPaths, handleTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index e9854b259305..acd2580385ae 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -191,6 +191,24 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s } } +/** + * Enables the managed tunnel on a client whose `init` didn't, from an option a framework SDK received elsewhere, + * like a build option, and drops the tunnel's spans from then on. A managed tunnel the `init` enabled wins. + */ +export function enableManagedTunnel(client: Client | undefined, tunnelOptions: TunnelOptions): void { + const options = client?.getOptions(); + if (!options || options._managedTunnel) { + return; + } + + options._managedTunnel = tunnelOptions; + + const spanFilters = getTunnelIgnoreSpanFilters(tunnelOptions, client?.getDsn()); + if (spanFilters.length) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; + } +} + /** * Returns true if the request targets the managed tunnel. * From ca874835b3a7a23dc28993bc12e712e676eacc9c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:55:15 -0400 Subject: [PATCH 22/40] ref(core): Settle a server client's managed tunnel in one place, from init or framework defaults Framework SDKs report tunnel options and the base path once with setManagedTunnelDefaults, and ServerRuntimeClient applies them when it's created, so per-request clients get the span filters before their first span starts. The span filters move out of the base Client into the server client, and the server-only helpers move to @sentry/core/server. --- packages/core/src/client.ts | 8 --- packages/core/src/index.ts | 8 +-- packages/core/src/server-runtime-client.ts | 2 + packages/core/src/server.ts | 8 +++ .../core/src/utils/managedTunnelDefaults.ts | 72 +++++++++++++++++++ packages/core/src/utils/tunnel.ts | 64 ++--------------- packages/core/test/lib/utils/tunnel.test.ts | 72 ++++++++++++++----- 7 files changed, 143 insertions(+), 91 deletions(-) create mode 100644 packages/core/src/utils/managedTunnelDefaults.ts diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 621faec3dc49..c61b3975d41b 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,7 +41,6 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; -import { getTunnelIgnoreSpanFilters } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -272,13 +271,6 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } - const tunnelSpanFilters = options._managedTunnel - ? getTunnelIgnoreSpanFilters(options._managedTunnel, this._dsn) - : []; - if (tunnelSpanFilters.length) { - this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), ...tunnelSpanFilters]; - } - const { beforeSendSpan, traceLifecycle } = this._options; // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, // so a mismatch means it is silently never called. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad62826a3f51..b7172593df2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,18 +101,12 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; -export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; +export type { HandleTunnelRequestOptions } from './utils/tunnel'; export { applyTunnelDefaults, - enableManagedTunnel, getTunnelPath, - getTunnelPaths, handleTunnelRequest, - handleTunnelRequestIfMatched, - isTunnelRequest, normalizeTunnelBasePath, - resolveServerTunnelOption, - setManagedTunnelBasePath, TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 159c4aeb3b93..9dcc4512bea4 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -21,6 +21,7 @@ import { uuid4 } from './utils/misc'; import { makePromiseBuffer } from './utils/promisebuffer'; import { resolvedSyncPromise } from './utils/syncpromise'; import { _getTraceInfoFromScope } from './utils/trace-info'; +import { applyManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -58,6 +59,7 @@ export class ServerRuntimeClient< super(options); + applyManagedTunnelDefaults(this); this._setUpMetricsProcessing(); } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index c6d78b00f52d..360934937894 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -6,6 +6,14 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; +export type { TunnelRequestLike } from './utils/tunnel'; +export { + getTunnelPaths, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; +export { _INTERNAL_resetManagedTunnelDefaults, setManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts new file mode 100644 index 000000000000..df84bd3be34f --- /dev/null +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -0,0 +1,72 @@ +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, withPathBelowBasePath } from './tunnel'; + +/** + * Managed tunnel settings a framework SDK knows outside `init`. + */ +interface ManagedTunnelDefaults { + /** Enables the managed tunnel, e.g. from TanStack Start's `tunnelRoute` build option. */ + tunnel?: TunnelOptions; + /** The path prefix the app is served under. */ + basePath?: string; +} + +let managedTunnelDefaults: ManagedTunnelDefaults | undefined; + +// The span filters each client got for its managed tunnel, so applying new defaults replaces them. +const installedSpanFilters = new WeakMap(); + +/** + * Sets the managed tunnel settings a framework SDK knows outside `init`, for every server client created afterwards + * and for the current one. Clients created per request, like on Cloudflare, then start with the tunnel and its span + * filters, so not even their first tunnel request's span gets through. A managed tunnel or `basePath` that `init` + * set wins. + * + * Framework SDKs call this once, when their server code loads. + */ +export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { + managedTunnelDefaults = { + ...managedTunnelDefaults, + ...(defaults.tunnel && { tunnel: defaults.tunnel }), + ...(defaults.basePath !== undefined && { basePath: defaults.basePath }), + }; + applyManagedTunnelDefaults(getClient()); +} + +/** + * Settles a server client's managed tunnel: the one its `init` enabled, or else the defaults, under the default base + * path, and installs the span filters for it. Server clients call this when they're created, and + * {@link setManagedTunnelDefaults} calls it again for a client that already exists, replacing the filters it added. + */ +export function applyManagedTunnelDefaults(client: Client | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; + if (!client || !options || !tunnelOptions) { + return; + } + + const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); + const resolved = withPathBelowBasePath( + tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, + ); + options._managedTunnel = resolved; + + const previousFilters = installedSpanFilters.get(client) ?? []; + const spanFilters = getTunnelIgnoreSpanFilters(resolved, client.getDsn()); + options.ignoreSpans = [ + ...(options.ignoreSpans ?? []).filter(filter => !(previousFilters as unknown[]).includes(filter)), + ...spanFilters, + ]; + installedSpanFilters.set(client, spanFilters); +} + +/** + * Resets {@link setManagedTunnelDefaults}, for tests. + * + * @internal + */ +export function _INTERNAL_resetManagedTunnelDefaults(): void { + managedTunnelDefaults = undefined; +} diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index acd2580385ae..8c3caad3657f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,5 +1,4 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; -import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -112,21 +111,8 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { } /** - * An `ignoreSpans` filter for the server spans of tunnel requests. Server SDKs start the span before a framework - * hook gets to answer the request, so clients add this whenever the managed tunnel is enabled. - */ -export function getTunnelIgnoreSpanFilter( - tunnelOptions: TunnelOptions, - dsn: DsnComponents | undefined, -): IgnoreSpanFilter | undefined { - const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); - - // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return pathPattern ? { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } } : undefined; -} - -/** - * The `ignoreSpans` filters a client installs for tunnel requests. Besides `url.path`, they match the legacy + * The `ignoreSpans` filters a server client installs for the server spans of tunnel requests, which server SDKs start + * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. */ export function getTunnelIgnoreSpanFilters( @@ -171,44 +157,6 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { return !path || path.startsWith('/') ? path : `/${path}`; } -/** - * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime - * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. - */ -export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { - const options = client?.getOptions(); - const tunnelOptions = options?._managedTunnel; - const normalizedBasePath = normalizeTunnelBasePath(basePath); - if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { - return; - } - - options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); - - const spanFilters = getTunnelIgnoreSpanFilters(options._managedTunnel, client?.getDsn()); - if (spanFilters.length) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; - } -} - -/** - * Enables the managed tunnel on a client whose `init` didn't, from an option a framework SDK received elsewhere, - * like a build option, and drops the tunnel's spans from then on. A managed tunnel the `init` enabled wins. - */ -export function enableManagedTunnel(client: Client | undefined, tunnelOptions: TunnelOptions): void { - const options = client?.getOptions(); - if (!options || options._managedTunnel) { - return; - } - - options._managedTunnel = tunnelOptions; - - const spanFilters = getTunnelIgnoreSpanFilters(tunnelOptions, client?.getDsn()); - if (spanFilters.length) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; - } -} - /** * Returns true if the request targets the managed tunnel. * @@ -234,12 +182,12 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOpti * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller * can hand the request to the app. */ -export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { - if (!isTunnelRequest(request, options)) { +export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { + if (!isTunnelRequest(request)) { return undefined; } - const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + const allowedDsns = getClient()?.getOptions()._managedTunnel?.allowedDsns; // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes // the browser send directly, instead of losing every envelope to an error response. if (!resolveAllowedDsns(allowedDsns).length) { @@ -460,7 +408,7 @@ function resolveAllowedDsns( * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to * be before the SDK added the base path, would otherwise be served under the base path twice. */ -function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { +export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); return path ? { ...tunnelOptions, path } : tunnelOptions; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 11357a314fac..3c9182808076 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -7,20 +7,32 @@ import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { applyTunnelDefaults, getTunnelPath, - getTunnelIgnoreSpanFilter, + getTunnelIgnoreSpanFilters, getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, normalizeTunnelBasePath, resolveServerTunnelOption, - setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; -import type { TunnelOptions } from '../../../src/types/options'; +import { + _INTERNAL_resetManagedTunnelDefaults, + applyManagedTunnelDefaults, + setManagedTunnelDefaults, +} from '../../../src/utils/managedTunnelDefaults'; +import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { ServerRuntimeClient } from '../../../src/server-runtime-client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +/** The `url.path` filter, the first of the tunnel span filters. */ +function getTunnelIgnoreSpanFilter( + ...args: Parameters +): IgnoreSpanFilter | undefined { + return getTunnelIgnoreSpanFilters(...args)[0]; +} + function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); @@ -508,9 +520,10 @@ describe('handleTunnelRequestIfMatched', () => { it('forwards a matching request', async () => { fetchMock.mockResolvedValueOnce(new Response('ok')); + setTestClient(TEST_DSN, {}); const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); - const result = await handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] }); + const result = await handleTunnelRequestIfMatched(request); expect(result?.status).toBe(200); expect(fetchMock).toHaveBeenCalledOnce(); @@ -536,14 +549,14 @@ describe('handleTunnelRequestIfMatched', () => { const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); - expect(handleTunnelRequestIfMatched(request, { path: '/monitor' })).toBeUndefined(); expect(request.bodyUsed).toBe(false); }); it('returns undefined without reading the body when the request does not match', () => { + setTestClient(TEST_DSN, {}); const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); - expect(handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] })).toBeUndefined(); + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); expect(request.bodyUsed).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -594,9 +607,9 @@ describe('getTunnelIgnoreSpanFilter', () => { }); }); -describe('client with the managed tunnel enabled', () => { +describe('server client with the managed tunnel enabled', () => { it('ignores the server spans of tunnel requests and keeps user filters', () => { - const client = new TestClient( + const client = new ServerRuntimeClient( getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), ); const ignoreSpans = client.getOptions().ignoreSpans!; @@ -607,10 +620,13 @@ describe('client with the managed tunnel enabled', () => { ).toBe(true); }); - it('leaves ignoreSpans alone without the managed tunnel', () => { - const client = new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN })); - - expect(client.getOptions().ignoreSpans).toBeUndefined(); + it('leaves ignoreSpans alone without the managed tunnel, and in non-server clients', () => { + expect( + new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN })).getOptions().ignoreSpans, + ).toBeUndefined(); + expect( + new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })).getOptions().ignoreSpans, + ).toBeUndefined(); }); }); @@ -685,16 +701,17 @@ describe('normalizeTunnelBasePath', () => { }); }); -describe('setManagedTunnelBasePath', () => { +describe('setManagedTunnelDefaults', () => { afterEach(() => { getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); }); it('serves the tunnel under the base path and drops its spans there', () => { setTestClient(TEST_DSN, {}); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app/'); + setManagedTunnelDefaults({ basePath: '/app/' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); @@ -708,7 +725,7 @@ describe('setManagedTunnelBasePath', () => { setTestClient(TEST_DSN, { path: '/app/monitor' }); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); @@ -718,19 +735,38 @@ describe('setManagedTunnelBasePath', () => { it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { setTestClient(TEST_DSN, { basePath: '/custom' }); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + _INTERNAL_resetManagedTunnelDefaults(); setTestClient(TEST_DSN, {}); const rootClient = getCurrentScope().getClient()!; - setManagedTunnelBasePath(rootClient, '/'); + setManagedTunnelDefaults({ basePath: '/' }); expect(rootClient.getOptions()._managedTunnel).toEqual({}); + _INTERNAL_resetManagedTunnelDefaults(); setTestClient(TEST_DSN); const offClient = getCurrentScope().getClient()!; - setManagedTunnelBasePath(offClient, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(offClient.getOptions()._managedTunnel).toBeUndefined(); }); + + it('enables the tunnel on clients created later, unless their `init` set one', () => { + setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, basePath: '/app' }); + + setTestClient(TEST_DSN); + const laterClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(laterClient); + expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor', basePath: '/app' }); + expect(laterClient.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ path: '/monitor', basePath: '/app' }, makeDsn(TEST_DSN)), + ); + + setTestClient(TEST_DSN, { path: '/from-init' }); + const initClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(initClient); + expect(initClient.getOptions()._managedTunnel).toEqual({ path: '/from-init', basePath: '/app' }); + }); }); function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { From 64bc21bd8d1da0800700e95ef4e855a2437e7330 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 11:26:56 -0400 Subject: [PATCH 23/40] test(nextjs): Read back the streamed body in the tunnel route test The core tunnel handler now streams the envelope to Sentry instead of buffering it, so the forwarded body is a stream. --- packages/nextjs/test/tunnel/index.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 0df557baa736..516543a2b5a9 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -59,12 +59,14 @@ describe('POST', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( 'https://o1.ingest.sentry.io/api/1/envelope/?sentry_version=7&sentry_key=public', - { + expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/x-sentry-envelope' }, - body, - }, + }), ); + // The body streams through, so it is read back to compare it. + const [, init] = fetchSpy.mock.calls[0]!; + expect(new Uint8Array(await new Response((init as RequestInit).body).arrayBuffer())).toEqual(body); }); it('responds with 500 when the server SDK has no DSN', async () => { From 52c892ca76208815a34a00ce747979c126499e3d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:13:58 -0400 Subject: [PATCH 24/40] ref(core): Compute the managed tunnel paths once per server client The paths only depend on the client's tunnel options and DSN, so they're derived when the managed tunnel is settled and read per request. This drops the path cache, which the browser never needed, and the options forms of `isTunnelRequest` and `getTunnelPaths`. --- packages/core/src/types/options.ts | 7 ++ .../core/src/utils/managedTunnelDefaults.ts | 15 +++- packages/core/src/utils/tunnel.ts | 87 ++++++------------ packages/core/test/lib/utils/tunnel.test.ts | 88 ++++++++++++------- 4 files changed, 102 insertions(+), 95 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index d2896dd3979b..2a877219f01b 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -381,6 +381,13 @@ export interface ClientOptions !(previousFilters as unknown[]).includes(filter)), ...spanFilters, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 8c3caad3657f..08d9772378ee 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -20,10 +20,6 @@ export interface HandleTunnelRequestOptions { allowedDsns?: Array; } -// Servers derive the paths on every POST, so parse and hash each DSN once. This also keeps an invalid DSN in -// `allowedDsns` from logging on every request. Keys come from config, never from requests, so the map stays small. -const tunnelPathCache = new Map(); - /** * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. @@ -71,17 +67,9 @@ export function applyTunnelDefaults( * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. */ export function getTunnelPath(dsn: DsnLike): string | undefined { - if (typeof dsn === 'string' && tunnelPathCache.has(dsn)) { - return tunnelPathCache.get(dsn); - } - const components = makeDsn(dsn); - const path = components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; - if (typeof dsn === 'string') { - tunnelPathCache.set(dsn, path); - } - return path; + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; } /** @@ -96,18 +84,27 @@ export interface TunnelRequestLike { } /** - * Returns the tunnel paths the server accepts, relative to its base path. Frameworks that can only register routes - * use these, everything else should call {@link isTunnelRequest} or {@link handleTunnelRequestIfMatched}. - * - * Without `options`, this uses the managed tunnel config of the active client. + * Returns the tunnel paths the active server client accepts, relative to its base path. Frameworks that can only + * register routes use these, everything else should call {@link isTunnelRequest} or + * {@link handleTunnelRequestIfMatched}. */ -export function getTunnelPaths(options?: TunnelOptions): string[] { - const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; - if (!tunnelOptions) { - return []; +export function getTunnelPaths(): string[] { + return getClient()?.getOptions()._managedTunnelPaths ?? []; +} + +/** + * The paths a server client serves its managed tunnel at: the fixed `path`, or else the path of each allowed DSN and + * of its own. Server clients compute them once, when their managed tunnel is settled, since neither changes later. + */ +export function resolveTunnelPaths(tunnelOptions: TunnelOptions, ownDsn: DsnComponents | undefined): string[] { + if (tunnelOptions.path) { + return [tunnelOptions.path.replace(/\/$/, '')]; } - return tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns)); + // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + const paths = resolveAllowedDsns(tunnelOptions.allowedDsns, ownDsn).map(dsn => getTunnelPath(dsn)); + + return [...new Set(paths.filter((path): path is string => !!path))]; } /** @@ -115,15 +112,14 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. */ -export function getTunnelIgnoreSpanFilters( - tunnelOptions: TunnelOptions, - dsn: DsnComponents | undefined, -): IgnoreSpanFilter[] { - const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); - if (!pathPattern) { +export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: string[]): IgnoreSpanFilter[] { + if (!paths.length) { return []; } + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); + const pathPattern = `${basePath}(?:${paths.map(escapeStringForRegex).join('|')})/?`; + return [ // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, @@ -132,22 +128,6 @@ export function getTunnelIgnoreSpanFilters( ]; } -/** - * The tunnel paths under the base path, as a regex source matching a pathname with an optional trailing slash. - */ -function getTunnelPathPattern(tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined): string | undefined { - const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( - escapeStringForRegex, - ); - if (!paths.length) { - return undefined; - } - - const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); - - return `${basePath}(?:${paths.join('|')})/?`; -} - /** * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. */ @@ -158,24 +138,22 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { } /** - * Returns true if the request targets the managed tunnel. - * - * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its + * Returns true if the request targets the managed tunnel of the active client, which matches nothing unless its * `init` was called with `tunnel: true`. */ -export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOptions): boolean { +export function isTunnelRequest(request: TunnelRequestLike): boolean { if (request.method !== 'POST' || !request.url) { return false; } - const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + const tunnelOptions = getClient()?.getOptions()._managedTunnel; if (!tunnelOptions) { return false; } const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); - return path !== undefined && getTunnelPaths(tunnelOptions).includes(path.replace(/\/$/, '')); + return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, '')); } /** @@ -443,12 +421,3 @@ function fnv1a(input: string): number { // eslint-disable-next-line no-bitwise return hash >>> 0; } - -function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { - if (tunnelOptions.path) { - return [tunnelOptions.path.replace(/\/$/, '')]; - } - - // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. - return [...new Set(allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path))]; -} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 3c9182808076..cccfc52beb4c 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -14,12 +14,14 @@ import { isTunnelRequest, normalizeTunnelBasePath, resolveServerTunnelOption, + resolveTunnelPaths, } from '../../../src/utils/tunnel'; import { _INTERNAL_resetManagedTunnelDefaults, applyManagedTunnelDefaults, setManagedTunnelDefaults, } from '../../../src/utils/managedTunnelDefaults'; +import type { DsnComponents } from '../../../src/types/dsn'; import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; import { ServerRuntimeClient } from '../../../src/server-runtime-client'; @@ -28,9 +30,10 @@ const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; /** The `url.path` filter, the first of the tunnel span filters. */ function getTunnelIgnoreSpanFilter( - ...args: Parameters + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { - return getTunnelIgnoreSpanFilters(...args)[0]; + return getTunnelIgnoreSpanFilters(tunnelOptions, resolveTunnelPaths(tunnelOptions, dsn))[0]; } function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { @@ -375,16 +378,6 @@ describe('getTunnelPath', () => { it('returns undefined for an invalid DSN', () => { expect(getTunnelPath('not-a-dsn')).toBeUndefined(); }); - - it('parses each DSN string once, so an invalid DSN logs once', () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - getTunnelPath('also-not-a-dsn'); - getTunnelPath('also-not-a-dsn'); - - expect(consoleErrorSpy).toHaveBeenCalledOnce(); - consoleErrorSpy.mockRestore(); - }); }); describe('isTunnelRequest', () => { @@ -395,20 +388,22 @@ describe('isTunnelRequest', () => { }); it('matches a POST to the tunnel path of an allowed DSN', () => { - expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); - expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('does not match other methods, other paths, or path prefixes', () => { - expect(isTunnelRequest({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('does not match the tunnel path of a DSN that is not allowed', () => { const otherDsn = 'https://other@example.com/9999'; - expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe( + false, + ); }); it('uses the managed tunnel config of the active client when no options are passed', () => { @@ -421,43 +416,54 @@ describe('isTunnelRequest', () => { it('matches nothing without a client', () => { expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); - expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, {})).toBe(false); }); it('matches under a base path', () => { expect( - isTunnelRequest({ method: 'POST', url: `/docs${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs/' }), + isTunnelRequestFor( + { method: 'POST', url: `/docs${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs/' }, + ), ).toBe(true); expect( - isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + isTunnelRequestFor( + { method: 'POST', url: `/docsx${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs' }, + ), ).toBe(false); }); it('matches nothing outside the base path', () => { - expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe( + expect( + isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe( false, ); - expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe(false); }); }); describe('isTunnelRequest request shapes', () => { const tunnelPath = getTunnelPath(TEST_DSN)!; + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + it('accepts a fetch Request with an absolute URL and query', () => { const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); - expect(isTunnelRequest(request, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor(request, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('accepts a Node IncomingMessage-like object', () => { const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; - expect(isTunnelRequest(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('does not match without a URL', () => { - expect(isTunnelRequest({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); }); }); @@ -469,22 +475,22 @@ describe('getTunnelPaths', () => { it('returns one path per allowed DSN and skips invalid ones', () => { const otherDsn = 'https://other@o2.ingest.sentry.io/42'; - expect(getTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] })).toEqual([ + expect(resolveTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] }, undefined)).toEqual([ getTunnelPath(TEST_DSN), getTunnelPath(otherDsn), ]); }); it('returns a fixed path instead of the DSN-derived ones', () => { - expect(getTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] })).toEqual(['/monitor']); - expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + expect(resolveTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] }, undefined)).toEqual(['/monitor']); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( true, ); }); it('matches a fixed path that ends with a slash', () => { - expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); - expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); }); it('serves the path of the active client DSN on top of allowedDsns, once', () => { @@ -503,6 +509,17 @@ describe('getTunnelPaths', () => { setTestClient(TEST_DSN, {}); expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); }); + + it('derives the paths once per client, so an invalid DSN in allowedDsns logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setTestClient(TEST_DSN, { allowedDsns: ['also-not-a-dsn'] }); + + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); }); describe('handleTunnelRequestIfMatched', () => { @@ -770,5 +787,12 @@ describe('setManagedTunnelDefaults', () => { }); function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { - setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); + setCurrentClient(new ServerRuntimeClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} + +/** `isTunnelRequest` for a server client with this managed tunnel and no DSN of its own. */ +function isTunnelRequestFor(request: Parameters[0], managedTunnel: TunnelOptions): boolean { + setTestClient(undefined, managedTunnel); + + return isTunnelRequest(request); } From 6fee43e25fe0dd0a5d9d61a4dcb948ec1cd8806a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:22:53 -0400 Subject: [PATCH 25/40] ref(core): Resolve the managed tunnel's DSNs once per server client The matcher forwarded through the public handler, which resolved the allowed DSNs again on every request. The client now stores them next to the tunnel paths, and the own DSN is passed explicitly, so a client without a DSN never picks up another client's. The header line is parsed as JSON directly, since it's all the handler reads. --- packages/core/src/types/options.ts | 8 ++++ .../core/src/utils/managedTunnelDefaults.ts | 10 ++-- packages/core/src/utils/tunnel.ts | 46 +++++++++---------- packages/core/test/lib/utils/tunnel.test.ts | 9 ++-- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 2a877219f01b..a601b89248f8 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -388,6 +388,14 @@ export interface ClientOptions): string[] { if (tunnelOptions.path) { return [tunnelOptions.path.replace(/\/$/, '')]; } // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. - const paths = resolveAllowedDsns(tunnelOptions.allowedDsns, ownDsn).map(dsn => getTunnelPath(dsn)); + const paths = allowedDsns.map(dsn => getTunnelPath(dsn)); return [...new Set(paths.filter((path): path is string => !!path))]; } @@ -161,18 +160,14 @@ export function isTunnelRequest(request: TunnelRequestLike): boolean { * can hand the request to the app. */ export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { - if (!isTunnelRequest(request)) { - return undefined; - } - - const allowedDsns = getClient()?.getOptions()._managedTunnel?.allowedDsns; + const allowedDsns = getClient()?.getOptions()._managedTunnelAllowedDsns; // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes // the browser send directly, instead of losing every envelope to an error response. - if (!resolveAllowedDsns(allowedDsns).length) { + if (!allowedDsns?.length || !isTunnelRequest(request)) { return undefined; } - return handleTunnelRequest({ request, allowedDsns }); + return forwardTunnelRequest(request, allowedDsns); } /** @@ -183,8 +178,8 @@ export function handleTunnelRequestIfMatched(request: Request): Promise { - return markTunnelResponse(await forwardTunnelRequest(options)); +export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + return forwardTunnelRequest(options.request, resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn())); } /** @@ -203,10 +198,11 @@ function markTunnelResponse(response: Response): Response { return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } -async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request } = options; - const allowedDsns = resolveAllowedDsns(options.allowedDsns); +async function forwardTunnelRequest(request: Request, allowedDsns: Array): Promise { + return markTunnelResponse(await forwardEnvelope(request, allowedDsns)); +} +async function forwardEnvelope(request: Request, allowedDsns: Array): Promise { if (allowedDsns.length === 0) { return new Response('Tunnel not configured', { status: 500 }); } @@ -248,9 +244,10 @@ async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promis * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. */ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { - let envelopeHeader; + let envelopeHeader: { dsn?: string } | null; try { - [envelopeHeader] = parseEnvelope(headerLine); + // Only the header line is read, and it's a single JSON object. + envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -373,13 +370,16 @@ function forwardHeaders(request: Request): Record { * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock * out a browser that shares it. */ -function resolveAllowedDsns( +export function resolveAllowedDsns( allowedDsns: Array | undefined, - ownDsn: DsnComponents | undefined = getClient()?.getDsn(), + ownDsn: DsnComponents | undefined, ): Array { - const dsns = [...(allowedDsns ?? []), ...(ownDsn ? [dsnToString(ownDsn)] : [])]; + const dsns = new Set(allowedDsns); + if (ownDsn) { + dsns.add(dsnToString(ownDsn)); + } - return [...new Set(dsns)]; + return [...dsns]; } /** diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index cccfc52beb4c..02a35879401f 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -13,6 +13,7 @@ import { handleTunnelRequestIfMatched, isTunnelRequest, normalizeTunnelBasePath, + resolveAllowedDsns, resolveServerTunnelOption, resolveTunnelPaths, } from '../../../src/utils/tunnel'; @@ -33,7 +34,9 @@ function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { - return getTunnelIgnoreSpanFilters(tunnelOptions, resolveTunnelPaths(tunnelOptions, dsn))[0]; + const paths = resolveTunnelPaths(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)); + + return getTunnelIgnoreSpanFilters(tunnelOptions, paths)[0]; } function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { @@ -475,14 +478,14 @@ describe('getTunnelPaths', () => { it('returns one path per allowed DSN and skips invalid ones', () => { const otherDsn = 'https://other@o2.ingest.sentry.io/42'; - expect(resolveTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] }, undefined)).toEqual([ + expect(resolveTunnelPaths({}, [TEST_DSN, otherDsn, 'not-a-dsn'])).toEqual([ getTunnelPath(TEST_DSN), getTunnelPath(otherDsn), ]); }); it('returns a fixed path instead of the DSN-derived ones', () => { - expect(resolveTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] }, undefined)).toEqual(['/monitor']); + expect(resolveTunnelPaths({ path: '/monitor' }, [TEST_DSN])).toEqual(['/monitor']); expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( true, ); From 433fd6415f31fa3e7deec6702c621aa3d6dd0821 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:42:25 -0400 Subject: [PATCH 26/40] fix(core): Don't forward the tunnel request's Content-Length with the streamed body Next.js's edge runtime in dev rejects a Content-Length header on a streamed fetch body, so every forward failed with a 500. Relay takes the chunked upload. --- packages/core/src/utils/tunnel.ts | 7 ++----- packages/core/test/lib/utils/tunnel.test.ts | 21 --------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index cd125c26e5e9..79f000127df2 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -346,12 +346,9 @@ function prependToStream( } function forwardHeaders(request: Request): Record { + // No `Content-Length`: the body is a stream, and some runtimes' `fetch` rejects the header on one (Next.js's edge + // runtime in dev). const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; - // Keeps the upload fixed-length where the runtime allows it (Node, Bun), instead of chunked. - const contentLength = request.headers.get('content-length'); - if (contentLength) { - headers['Content-Length'] = contentLength; - } // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the // visitor's IP in these headers, in the order Relay itself reads them. diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 02a35879401f..787e1c3f7a00 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -121,27 +121,6 @@ describe('handleTunnelRequest', () => { expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); }); - it('forwards the content length of the incoming request', async () => { - fetchMock.mockResolvedValueOnce(new Response('ok')); - const body = `${JSON.stringify({ dsn: TEST_DSN })}\n`; - - await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { - method: 'POST', - body, - headers: { 'content-length': String(body.length) }, - }), - allowedDsns: [TEST_DSN], - }); - - expect(fetchMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: { 'Content-Type': 'application/x-sentry-envelope', 'Content-Length': String(body.length) }, - }), - ); - }); - it.each([ ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], From 688680f6caa852ba20fe4fff5415b9ee54e511e6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 14:14:37 -0400 Subject: [PATCH 27/40] ref(core): Keep the served tunnel paths and DSNs out of ClientOptions They're derived per client, so core keeps them in its own map instead of on the public options type. --- packages/core/src/types/options.ts | 15 ---------- .../core/src/utils/managedTunnelDefaults.ts | 8 +++-- packages/core/src/utils/tunnel.ts | 30 ++++++++++++++++--- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index a601b89248f8..d2896dd3979b 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -381,21 +381,6 @@ export interface ClientOptions !(previousFilters as unknown[]).includes(filter)), ...spanFilters, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 79f000127df2..75d7c6556224 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,4 +1,5 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -9,8 +10,29 @@ import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseUrl } from './url'; -// Bounds the memory of each request. SDK envelopes stay far below it. -export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; +/** + * What a server client serves its managed tunnel at and forwards for. Both only depend on its tunnel options and DSN, + * so they're computed once, when its managed tunnel is settled, and only read per request. + */ +interface ServedTunnel { + paths: string[]; + allowedDsns: string[]; +} + +const servedTunnels = new WeakMap(); + +/** + * Records what a server client serves its managed tunnel at and forwards for. + */ +export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { + servedTunnels.set(client, servedTunnel); +} + +function getServedTunnel(): ServedTunnel | undefined { + const client = getClient(); + + return client && servedTunnels.get(client); +} export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ @@ -88,7 +110,7 @@ export interface TunnelRequestLike { * {@link handleTunnelRequestIfMatched}. */ export function getTunnelPaths(): string[] { - return getClient()?.getOptions()._managedTunnelPaths ?? []; + return getServedTunnel()?.paths ?? []; } /** @@ -160,7 +182,7 @@ export function isTunnelRequest(request: TunnelRequestLike): boolean { * can hand the request to the app. */ export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { - const allowedDsns = getClient()?.getOptions()._managedTunnelAllowedDsns; + const allowedDsns = getServedTunnel()?.allowedDsns; // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes // the browser send directly, instead of losing every envelope to an error response. if (!allowedDsns?.length || !isTunnelRequest(request)) { From 830abd6f964fbb8a14277e154febbad13eea1a78 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 14:19:39 -0400 Subject: [PATCH 28/40] ref(core): Trim the managed tunnel comments --- packages/core/src/integrations/fetch.ts | 2 +- packages/core/src/types/options.ts | 6 +- .../core/src/utils/managedTunnelDefaults.ts | 16 ++-- packages/core/src/utils/tunnel.ts | 86 +++++++------------ 4 files changed, 38 insertions(+), 72 deletions(-) diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index dd5fe7e16ced..cc57d5edca45 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -93,7 +93,7 @@ export function createFetchIntegration({ return; } - // Matches Node's http and undici instrumentation, so SDK-internal requests (e.g. a tunnel forward) leave no breadcrumb. + // Like Node's http and undici instrumentation: SDK-internal requests leave no breadcrumb. if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index d2896dd3979b..9cff5450e374 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,8 +374,7 @@ export interface ClientOptions SpanJSON; /** Options for the managed tunnel, see `tunnel`. */ export interface TunnelOptions { /** - * Server only. DSNs whose envelopes the tunnel forwards, in addition to the server's own DSN. List the browser DSN - * here when the browser uses a different one. + * Server only. DSNs to forward besides the server's own, e.g. a different browser DSN. */ allowedDsns?: string[]; diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index db2d1a489067..89eefbcf711c 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -22,16 +22,12 @@ interface ManagedTunnelDefaults { let managedTunnelDefaults: ManagedTunnelDefaults | undefined; -// The span filters each client got for its managed tunnel, so applying new defaults replaces them. +// So re-applying defaults replaces a client's filters instead of adding more. const installedSpanFilters = new WeakMap(); /** - * Sets the managed tunnel settings a framework SDK knows outside `init`, for every server client created afterwards - * and for the current one. Clients created per request, like on Cloudflare, then start with the tunnel and its span - * filters, so not even their first tunnel request's span gets through. A managed tunnel or `basePath` that `init` - * set wins. - * - * Framework SDKs call this once, when their server code loads. + * Sets framework-known tunnel settings for the current and every later server client, so per-request clients + * (Cloudflare) start with them. Settings from `init` win. Framework SDKs call this once at load. */ export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { managedTunnelDefaults = { @@ -43,10 +39,8 @@ export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void } /** - * Settles a server client's managed tunnel: the one its `init` enabled, or else the defaults, under the default base - * path. It computes the DSNs the client forwards for and the paths it's served at, and installs the span filters for - * them. Server clients call this when they're created, and {@link setManagedTunnelDefaults} calls it again for a - * client that already exists, replacing the filters it added. + * Resolves a server client's managed tunnel (from `init` or the defaults), computes its paths and allowed DSNs, and + * installs its span filters. Runs when a server client is created and from {@link setManagedTunnelDefaults}. */ export function applyManagedTunnelDefaults(client: Client | undefined): void { const options = client?.getOptions(); diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 75d7c6556224..c3d3d022be82 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -11,8 +11,7 @@ import { dsnToString, makeDsn } from './dsn'; import { parseUrl } from './url'; /** - * What a server client serves its managed tunnel at and forwards for. Both only depend on its tunnel options and DSN, - * so they're computed once, when its managed tunnel is settled, and only read per request. + * A server client's tunnel paths and allowed DSNs, computed once per client. */ interface ServedTunnel { paths: string[]; @@ -22,7 +21,7 @@ interface ServedTunnel { const servedTunnels = new WeakMap(); /** - * Records what a server client serves its managed tunnel at and forwards for. + * Stores a server client's computed tunnel paths and allowed DSNs. */ export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { servedTunnels.set(client, servedTunnel); @@ -42,8 +41,7 @@ export interface HandleTunnelRequestOptions { } /** - * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own - * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. + * Splits a server `tunnel` option: a URL stays the transport tunnel, anything else enables the managed tunnel. */ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): { tunnel: string | undefined; @@ -54,7 +52,7 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions } if (typeof tunnel === 'string') { - // A relative path can't be a transport URL on the server, so it names the path to serve instead. + // A relative path can't be a transport URL on the server, so it's the path to serve. return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; } @@ -62,9 +60,7 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions } /** - * Fills in the defaults a framework SDK knows about its app, like the base path, for a managed tunnel. Options the - * user set win. A relative `tunnel` path is a managed tunnel with that `path`, so it gets the defaults too, while a - * URL and an unset `tunnel` are returned as they are. + * Fills in framework defaults (like the base path) for a managed tunnel. User options win; URLs pass through. */ export function applyTunnelDefaults( tunnel: string | true | TunnelOptions | undefined, @@ -82,10 +78,8 @@ export function applyTunnelDefaults( } /** - * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. - * - * Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it - * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. + * 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. */ export function getTunnelPath(dsn: DsnLike): string | undefined { const components = makeDsn(dsn); @@ -94,8 +88,7 @@ export function getTunnelPath(dsn: DsnLike): string | undefined { } /** - * The parts of an incoming request the tunnel decision may look at. A fetch `Request` satisfies it, and Node SDKs pass - * an `IncomingMessage` directly. Callers always pass the whole request so the decision can change without touching them. + * The parts of a request the tunnel match reads. Satisfied by a fetch `Request` and Node's `IncomingMessage`. */ export interface TunnelRequestLike { method?: string; @@ -105,33 +98,30 @@ export interface TunnelRequestLike { } /** - * Returns the tunnel paths the active server client accepts, relative to its base path. Frameworks that can only - * register routes use these, everything else should call {@link isTunnelRequest} or - * {@link handleTunnelRequestIfMatched}. + * Returns the active server client's tunnel paths, relative to its base path. For frameworks that can only register + * routes; everything else uses {@link handleTunnelRequestIfMatched}. */ export function getTunnelPaths(): string[] { return getServedTunnel()?.paths ?? []; } /** - * The paths a server client serves its managed tunnel at: the fixed `path`, or else the path of each DSN it forwards - * for. Server clients compute them once, when their managed tunnel is settled, since neither changes later. + * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. */ export function resolveTunnelPaths(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { if (tunnelOptions.path) { return [tunnelOptions.path.replace(/\/$/, '')]; } - // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + // Differently written DSNs can hash to the same path. const paths = allowedDsns.map(dsn => getTunnelPath(dsn)); return [...new Set(paths.filter((path): path is string => !!path))]; } /** - * The `ignoreSpans` filters a server client installs for the server spans of tunnel requests, which server SDKs start - * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy - * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. + * `ignoreSpans` filters for the server spans of tunnel requests. They match `url.path` and the legacy `http.url`, + * which some framework spans start with (SvelteKit's `sveltekit.handle.root`). */ export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: string[]): IgnoreSpanFilter[] { if (!paths.length) { @@ -150,7 +140,7 @@ export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: } /** - * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. + * Normalizes a base path to `/app`, or `''` for the root. */ export function normalizeTunnelBasePath(basePath: string | undefined): string { const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); @@ -159,8 +149,7 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { } /** - * Returns true if the request targets the managed tunnel of the active client, which matches nothing unless its - * `init` was called with `tunnel: true`. + * Returns true if the request targets the active server client's managed tunnel. */ export function isTunnelRequest(request: TunnelRequestLike): boolean { if (request.method !== 'POST' || !request.url) { @@ -178,13 +167,11 @@ export function isTunnelRequest(request: TunnelRequestLike): boolean { } /** - * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller - * can hand the request to the app. + * Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle. */ export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { const allowedDsns = getServedTunnel()?.allowedDsns; - // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes - // the browser send directly, instead of losing every envelope to an error response. + // Without a DSN to forward to, the app's 404 makes the browser send directly. if (!allowedDsns?.length || !isTunnelRequest(request)) { return undefined; } @@ -205,8 +192,7 @@ export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promis } /** - * Set on every response the tunnel handler returns, so a browser can tell it apart from whatever else answers the - * tunnel path, e.g. a static host serving `index.html` for every path. + * Marks responses from the tunnel handler, so the browser can tell them from e.g. a static host's `index.html`. */ export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; @@ -230,8 +216,7 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr } const reader = request.body?.getReader(); - // A client that drops the connection mid-upload makes the read throw. That is the client going away, not an app - // error, so it gets the same answer as any other unreadable envelope. + // A client aborting mid-upload makes the read throw. const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); if (!reader || !head) { return new Response('Invalid envelope', { status: 400 }); @@ -239,19 +224,16 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); if (typeof sentryIngestUrl !== 'string') { - // Nothing past the header gets forwarded, so the runtime can stop receiving the body. reader.cancel().catch(() => undefined); return sentryIngestUrl; } try { - // The forward is SDK plumbing, not app work, so it must not show up as a span. return await suppressTracing(() => fetch(sentryIngestUrl, { method: 'POST', headers: forwardHeaders(request), - // The body streams through without being buffered, so its size is left for Sentry to judge. body: prependToStream(head.read, reader), duplex: 'half', } as RequestInit), @@ -263,12 +245,11 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr } /** - * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. + * 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; try { - // Only the header line is read, and it's a single JSON object. envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); } catch { return new Response('Invalid envelope', { status: 400 }); @@ -303,13 +284,12 @@ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): s } /** - * The header is a small JSON object, so a line longer than this is not an envelope. + * A header line longer than this is not an envelope. */ const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; /** - * Reads the request body only up to the end of the envelope header line. - * Returns the line and every byte read so far, or `undefined` when no header line fits the limit. + * Reads the body up to the envelope header line. Returns the line and every byte read, or `undefined`. */ async function readEnvelopeHeaderLine( reader: ReadableStreamDefaultReader, @@ -368,12 +348,10 @@ function prependToStream( } function forwardHeaders(request: Request): Record { - // No `Content-Length`: the body is a stream, and some runtimes' `fetch` rejects the header on one (Next.js's edge - // runtime in dev). + // No `Content-Length`: some runtimes reject it on a streamed body (Next.js edge in dev). const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; - // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the - // visitor's IP in these headers, in the order Relay itself reads them. + // Relay would otherwise attribute the server's IP. Same precedence Relay uses. const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-vercel-forwarded-for') || @@ -386,8 +364,7 @@ function forwardHeaders(request: Request): Record { } /** - * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock - * out a browser that shares it. + * `allowedDsns` plus the server's own DSN, which is always allowed. */ export function resolveAllowedDsns( allowedDsns: Array | undefined, @@ -402,8 +379,7 @@ export function resolveAllowedDsns( } /** - * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to - * be before the SDK added the base path, would otherwise be served under the base path twice. + * Makes a fixed `path` relative to `basePath`, so one written with the base path isn't served under it twice. */ export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); @@ -412,7 +388,7 @@ export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptio } /** - * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. + * Returns the path below `basePath`, or `undefined` outside it. */ function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { const base = normalizeTunnelBasePath(basePath); @@ -424,10 +400,8 @@ function stripBasePath(pathname: string, basePath: string | undefined): string | } /** - * 32-bit FNV-1a, because the browser needs the path synchronously in `init` (`crypto.subtle.digest` is async) and it - * runs in every runtime without dependencies. It needn't be cryptographic since the DSN is public. - * - * Browser and server SDKs compute the path separately, so changing the algorithm breaks mixed SDK versions. + * 32-bit FNV-1a: synchronous (the browser needs it in `init`) and dependency free. Changing it breaks mixed SDK + * versions, since browser and server compute the path separately. */ function fnv1a(input: string): number { let hash = 0x811c9dc5; From 059e572bbe8ccf290700f83f4f5414e87040e2fb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 14:21:40 -0400 Subject: [PATCH 29/40] feat(core): Keep the tunnel body size checks with the streamed forward #25515's 411 and 413 checks run before the header line is read, so an oversized or unlengthed request is rejected without reading its body. --- packages/core/src/utils/tunnel.ts | 14 ++++++ packages/core/test/lib/utils/tunnel.test.ts | 50 ++++++++++++++++++--- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index c3d3d022be82..d686eadd8d97 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -33,6 +33,11 @@ function getServedTunnel(): ServedTunnel | undefined { return client && servedTunnels.get(client); } +/** + * Bounds what one tunnel request may send. SDK envelopes stay far below it. + */ +export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; + export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; @@ -215,6 +220,15 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr return new Response('Tunnel not configured', { status: 500 }); } + // The HTTP server ends the body at `Content-Length`, so the header bounds what the forward streams. + const contentLength = Number(request.headers.get('content-length')); + if (!contentLength) { + return new Response('Length required', { status: 411 }); + } + if (contentLength > MAX_TUNNEL_BODY_SIZE) { + return new Response('Envelope too large', { status: 413 }); + } + const reader = request.body?.getReader(); // A client aborting mid-upload makes the read throw. const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 787e1c3f7a00..55ebe21561f8 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -16,6 +16,7 @@ import { resolveAllowedDsns, resolveServerTunnelOption, resolveTunnelPaths, + MAX_TUNNEL_BODY_SIZE, } from '../../../src/utils/tunnel'; import { _INTERNAL_resetManagedTunnelDefaults, @@ -39,11 +40,15 @@ function getTunnelIgnoreSpanFilter( return getTunnelIgnoreSpanFilters(tunnelOptions, paths)[0]; } -function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { - const envelope = createEnvelope(envelopeHeader, []); - const body = serializeEnvelope(envelope); +function makeEnvelopeRequest( + envelopeHeader: Record, + url = 'http://localhost/tunnel', + contentLength?: number, +): Request { + const body = serializeEnvelope(createEnvelope(envelopeHeader, [])); + const length = contentLength ?? new TextEncoder().encode(body as string).length; - return new Request(url, { method: 'POST', body }); + return new Request(url, { method: 'POST', body, headers: { 'content-length': String(length) } }); } /** @@ -56,6 +61,7 @@ function makeEndlessRequest(head: string, onCancel?: () => void): Request { return new Request('http://localhost/tunnel', { method: 'POST', duplex: 'half', + headers: { 'content-length': String(MAX_TUNNEL_BODY_SIZE) }, body: new ReadableStream({ pull(controller) { controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); @@ -74,6 +80,40 @@ describe('handleTunnelRequest', () => { vi.stubGlobal('fetch', fetchMock); }); + it('forwards the envelope when Content-Length equals the limit', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(200); + }); + + it('returns 413 when Content-Length is above the limit, without reading the body', async () => { + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE + 1); + + const result = await handleTunnelRequest({ request, allowedDsns: [TEST_DSN] }); + + expect(result.status).toBe(413); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 411 when the request has no Content-Length', async () => { + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + }), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(411); + expect(fetchMock).not.toHaveBeenCalled(); + }); + afterEach(() => { vi.restoreAllMocks(); getCurrentScope().setClient(undefined); @@ -132,7 +172,7 @@ describe('handleTunnelRequest', () => { request: new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, - headers: incomingHeaders, + headers: { ...incomingHeaders, 'content-length': '1' }, }), allowedDsns: [TEST_DSN], }); From 41da2938318623e66f2fdbe97581616e37fb8d77 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:11:56 -0400 Subject: [PATCH 30/40] ref(core): Rename withPathBelowBasePath to stripBasePathFromTunnelPath --- packages/core/src/utils/managedTunnelDefaults.ts | 4 ++-- packages/core/src/utils/tunnel.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index 89eefbcf711c..19daffef2444 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -7,7 +7,7 @@ import { resolveAllowedDsns, resolveTunnelPaths, setServedTunnel, - withPathBelowBasePath, + stripBasePathFromTunnelPath, } from './tunnel'; /** @@ -50,7 +50,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void { } const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); - const resolved = withPathBelowBasePath( + const resolved = stripBasePathFromTunnelPath( tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, ); options._managedTunnel = resolved; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index d686eadd8d97..01b78ed9d692 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -76,10 +76,10 @@ export function applyTunnelDefaults( } if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? withPathBelowBasePath({ ...defaults, path: tunnel }) : tunnel; + return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...defaults, path: tunnel }) : tunnel; } - return withPathBelowBasePath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); + return stripBasePathFromTunnelPath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); } /** @@ -395,7 +395,7 @@ export function resolveAllowedDsns( /** * Makes a fixed `path` relative to `basePath`, so one written with the base path isn't served under it twice. */ -export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { +export function stripBasePathFromTunnelPath(tunnelOptions: TunnelOptions): TunnelOptions { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); return path ? { ...tunnelOptions, path } : tunnelOptions; From d525290d6b40a8ea0ff65c78dd49eedabd00cb81 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:53:05 -0400 Subject: [PATCH 31/40] ref(core): Normalize the default base path in applyTunnelDefaults and ignore a root one --- packages/core/src/utils/tunnel.ts | 10 +++++++--- packages/core/test/lib/utils/tunnel.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 01b78ed9d692..95a122c86384 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -66,20 +66,24 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions /** * Fills in framework defaults (like the base path) for a managed tunnel. User options win; URLs pass through. + * The base path is normalized, and a root one is no default at all. */ export function applyTunnelDefaults( tunnel: string | true | TunnelOptions | undefined, defaults: TunnelOptions | undefined, ): string | true | TunnelOptions | undefined { - if (!tunnel || !defaults || !Object.keys(defaults).length) { + const { basePath, ...rest } = defaults ?? {}; + const normalizedBasePath = normalizeTunnelBasePath(basePath); + const resolvedDefaults: TunnelOptions = normalizedBasePath ? { ...rest, basePath: normalizedBasePath } : rest; + if (!tunnel || !Object.keys(resolvedDefaults).length) { return tunnel; } if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...defaults, path: tunnel }) : tunnel; + return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...resolvedDefaults, path: tunnel }) : tunnel; } - return stripBasePathFromTunnelPath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); + return stripBasePathFromTunnelPath({ ...resolvedDefaults, ...(tunnel === true ? {} : tunnel) }); } /** diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 55ebe21561f8..bee197f7b89a 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -723,6 +723,12 @@ describe('applyTunnelDefaults', () => { expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); expect(applyTunnelDefaults(true, undefined)).toBe(true); }); + + it('normalizes the default base path and ignores a root one', () => { + expect(applyTunnelDefaults(true, { basePath: 'app/' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults(true, { basePath: '/' })).toBe(true); + expect(applyTunnelDefaults(true, { basePath: undefined })).toBe(true); + }); }); describe('normalizeTunnelBasePath', () => { From f32aca0d1b242d368404f57831d6eeeb407cad0d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 16:05:29 -0400 Subject: [PATCH 32/40] fix(core): Normalize allowedDsns like the envelope header's DSN, so one written with a secret key still matches --- packages/core/src/utils/tunnel.ts | 9 ++++++--- packages/core/test/lib/utils/tunnel.test.ts | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 95a122c86384..0d3df1d4d8b5 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -388,9 +388,12 @@ export function resolveAllowedDsns( allowedDsns: Array | undefined, ownDsn: DsnComponents | undefined, ): Array { - const dsns = new Set(allowedDsns); - if (ownDsn) { - dsns.add(dsnToString(ownDsn)); + // Normalized like the envelope header's DSN, which has no secret key. + const dsns = new Set(); + for (const components of [...(allowedDsns ?? []).map(dsn => makeDsn(dsn)), ownDsn]) { + if (components) { + dsns.add(dsnToString(components)); + } } return [...dsns]; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index bee197f7b89a..5723bf29e528 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -280,7 +280,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('returns 403 when the DSN string cannot be parsed into components', async () => { + it('ignores an allowed DSN that cannot be parsed', async () => { const malformedDsn = 'not-a-valid-dsn'; const result = await handleTunnelRequest({ @@ -288,12 +288,22 @@ describe('handleTunnelRequest', () => { allowedDsns: [malformedDsn], }); - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(403); - expect(await result.text()).toBe('Invalid DSN'); + expect(result.status).toBe(500); expect(fetchMock).not.toHaveBeenCalled(); }); + 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 }), + allowedDsns: [TEST_DSN.replace('@', ':secret@')], + }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it('forwards the envelope when multiple DSNs are configured', async () => { const otherDsn = 'https://other@example.com/9999'; const upstreamResponse = new Response('ok', { status: 200 }); From 41d9ff8ea62958a7ab48e8b59f4507ce3afc8521 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 09:58:42 -0400 Subject: [PATCH 33/40] ref(core): Export the tunnel helpers only SDKs use as `_INTERNAL_` --- packages/core/src/index.ts | 4 ++-- packages/core/src/server.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b7172593df2c..a5161966a58a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,10 +103,10 @@ export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; export { - applyTunnelDefaults, + applyTunnelDefaults as _INTERNAL_applyTunnelDefaults, getTunnelPath, handleTunnelRequest, - normalizeTunnelBasePath, + normalizeTunnelBasePath as _INTERNAL_normalizeTunnelBasePath, TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 360934937894..d032d9c49a87 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -8,12 +8,15 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; export type { TunnelRequestLike } from './utils/tunnel'; export { - getTunnelPaths, + getTunnelPaths as _INTERNAL_getTunnelPaths, handleTunnelRequestIfMatched, isTunnelRequest, - resolveServerTunnelOption, + resolveServerTunnelOption as _INTERNAL_resolveServerTunnelOption, } from './utils/tunnel'; -export { _INTERNAL_resetManagedTunnelDefaults, setManagedTunnelDefaults } from './utils/managedTunnelDefaults'; +export { + _INTERNAL_resetManagedTunnelDefaults, + setManagedTunnelDefaults as _INTERNAL_setManagedTunnelDefaults, +} from './utils/managedTunnelDefaults'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; From aa2b17b249ad5d0a4fa7df0bf2dc3741c34c21be Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:19:53 -0400 Subject: [PATCH 34/40] ref(core): Split the tunnel options into browser and server variants --- packages/core/src/index.ts | 8 +++++++- packages/core/src/types/options.ts | 20 +++++++++++-------- .../core/src/utils/managedTunnelDefaults.ts | 4 ++-- packages/core/src/utils/tunnel.ts | 10 +++++----- packages/core/test/lib/utils/tunnel.test.ts | 15 +++++++++----- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5161966a58a..8473fe4db74a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -421,7 +421,13 @@ export type { HttpHeadersCollection, ResolvedDataCollection, } from './types/datacollection'; -export type { ClientOptions, CoreOptions as Options, TunnelOptions } from './types/options'; +export type { + BrowserTunnelOptions, + ClientOptions, + CoreOptions as Options, + ServerTunnelOptions, + TunnelOptions, +} from './types/options'; export type { Package } from './types/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics'; export type { diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 9cff5450e374..bdc6bf499ebe 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -378,7 +378,7 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; -/** Options for the managed tunnel, see `tunnel`. */ +/** Managed tunnel options shared by the browser and the server `init`, see `tunnel`. */ export interface TunnelOptions { - /** - * Server only. DSNs to forward besides the server's own, e.g. a different browser DSN. - */ - allowedDsns?: string[]; - /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ path?: string; /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ basePath?: string; +} - /** Browser only. Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ +/** Managed tunnel options for the browser `init`. */ +export interface BrowserTunnelOptions extends TunnelOptions { + /** Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ trailingSlash?: boolean; } +/** Managed tunnel options for a server `init`. */ +export interface ServerTunnelOptions extends TunnelOptions { + /** DSNs to forward besides the server's own, e.g. a different browser DSN. */ + allowedDsns?: string[]; +} + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index 19daffef2444..5c0724abdd40 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -1,6 +1,6 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; -import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import type { IgnoreSpanFilter, ServerTunnelOptions } from '../types/options'; import { getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, @@ -15,7 +15,7 @@ import { */ interface ManagedTunnelDefaults { /** Enables the managed tunnel, e.g. from TanStack Start's `tunnelRoute` build option. */ - tunnel?: TunnelOptions; + tunnel?: ServerTunnelOptions; /** The path prefix the app is served under. */ basePath?: string; } diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 0d3df1d4d8b5..ebd415e212d1 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -4,7 +4,7 @@ import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; import type { DsnComponents, DsnLike } from '../types/dsn'; -import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import type { IgnoreSpanFilter, ServerTunnelOptions, TunnelOptions } from '../types/options'; import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; @@ -48,9 +48,9 @@ export interface HandleTunnelRequestOptions { /** * Splits a server `tunnel` option: a URL stays the transport tunnel, anything else enables the managed tunnel. */ -export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): { +export function resolveServerTunnelOption(tunnel: string | true | ServerTunnelOptions | undefined): { tunnel: string | undefined; - _managedTunnel?: TunnelOptions; + _managedTunnel?: ServerTunnelOptions; } { if (!tunnel) { return { tunnel }; @@ -117,7 +117,7 @@ export function getTunnelPaths(): string[] { /** * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. */ -export function resolveTunnelPaths(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { +export function resolveTunnelPaths(tunnelOptions: ServerTunnelOptions, allowedDsns: Array): string[] { if (tunnelOptions.path) { return [tunnelOptions.path.replace(/\/$/, '')]; } @@ -402,7 +402,7 @@ export function resolveAllowedDsns( /** * Makes a fixed `path` relative to `basePath`, so one written with the base path isn't served under it twice. */ -export function stripBasePathFromTunnelPath(tunnelOptions: TunnelOptions): TunnelOptions { +export function stripBasePathFromTunnelPath(tunnelOptions: T): T { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); return path ? { ...tunnelOptions, path } : tunnelOptions; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 5723bf29e528..4127d8b16df8 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -24,7 +24,7 @@ import { setManagedTunnelDefaults, } from '../../../src/utils/managedTunnelDefaults'; import type { DsnComponents } from '../../../src/types/dsn'; -import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; +import type { IgnoreSpanFilter, ServerTunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; import { ServerRuntimeClient } from '../../../src/server-runtime-client'; @@ -32,7 +32,7 @@ const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; /** The `url.path` filter, the first of the tunnel span filters. */ function getTunnelIgnoreSpanFilter( - tunnelOptions: TunnelOptions, + tunnelOptions: ServerTunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { const paths = resolveTunnelPaths(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)); @@ -703,8 +703,10 @@ describe('resolveServerTunnelOption', () => { describe('applyTunnelDefaults', () => { it('fills in the defaults for `true` and options, and lets options the user set win', () => { + const serverTunnel: ServerTunnelOptions = { allowedDsns: [TEST_DSN] }; + expect(applyTunnelDefaults(true, { basePath: '/app' })).toEqual({ basePath: '/app' }); - expect(applyTunnelDefaults({ allowedDsns: [TEST_DSN] }, { basePath: '/app' })).toEqual({ + expect(applyTunnelDefaults(serverTunnel, { basePath: '/app' })).toEqual({ basePath: '/app', allowedDsns: [TEST_DSN], }); @@ -824,12 +826,15 @@ describe('setManagedTunnelDefaults', () => { }); }); -function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { +function setTestClient(dsn: string | undefined, managedTunnel?: ServerTunnelOptions): void { setCurrentClient(new ServerRuntimeClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } /** `isTunnelRequest` for a server client with this managed tunnel and no DSN of its own. */ -function isTunnelRequestFor(request: Parameters[0], managedTunnel: TunnelOptions): boolean { +function isTunnelRequestFor( + request: Parameters[0], + managedTunnel: ServerTunnelOptions, +): boolean { setTestClient(undefined, managedTunnel); return isTunnelRequest(request); From 2774cafa234236d731fdf843a1bb154cd1138784 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:22:13 -0400 Subject: [PATCH 35/40] ref(core): Add a documented `TunnelInit` type for the `tunnel` option --- packages/core/src/index.ts | 1 + packages/core/src/types/options.ts | 13 +++++++++++++ packages/core/src/utils/tunnel.ts | 8 ++++---- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8473fe4db74a..1eb1d0ff4d72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -426,6 +426,7 @@ export type { ClientOptions, CoreOptions as Options, ServerTunnelOptions, + TunnelInit, TunnelOptions, } from './types/options'; export type { Package } from './types/package'; diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index bdc6bf499ebe..4f8bf39f117a 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -673,6 +673,19 @@ export interface ServerTunnelOptions extends TunnelOptions { allowedDsns?: string[]; } +/** + * The `tunnel` option, where to send envelopes instead of the Sentry ingest URL: + * + * - A URL, e.g. `https://example.com/tunnel`, sends envelopes to your own tunnel endpoint. + * - A relative path, e.g. `/monitor`, enables the managed tunnel at that fixed path. The browser sends envelopes to + * it and the server SDK serves it, so set the same path in both `init`s. + * - `true` enables the managed tunnel at a same-origin path derived from the DSN. Set it in both the browser and the + * server `init`: the browser sends envelopes to that path and the server SDK forwards them to Sentry. This keeps + * ad-blockers from dropping events. + * - Options, like `true`, with the browser or server options of the managed tunnel. + */ +export type TunnelInit = string | true | T; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index ebd415e212d1..90c5df1c9d64 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -4,7 +4,7 @@ import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; import type { DsnComponents, DsnLike } from '../types/dsn'; -import type { IgnoreSpanFilter, ServerTunnelOptions, TunnelOptions } from '../types/options'; +import type { IgnoreSpanFilter, ServerTunnelOptions, TunnelInit, TunnelOptions } from '../types/options'; import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; @@ -48,7 +48,7 @@ export interface HandleTunnelRequestOptions { /** * Splits a server `tunnel` option: a URL stays the transport tunnel, anything else enables the managed tunnel. */ -export function resolveServerTunnelOption(tunnel: string | true | ServerTunnelOptions | undefined): { +export function resolveServerTunnelOption(tunnel: TunnelInit | undefined): { tunnel: string | undefined; _managedTunnel?: ServerTunnelOptions; } { @@ -69,9 +69,9 @@ export function resolveServerTunnelOption(tunnel: string | true | ServerTunnelOp * The base path is normalized, and a root one is no default at all. */ export function applyTunnelDefaults( - tunnel: string | true | TunnelOptions | undefined, + tunnel: TunnelInit | undefined, defaults: TunnelOptions | undefined, -): string | true | TunnelOptions | undefined { +): TunnelInit | undefined { const { basePath, ...rest } = defaults ?? {}; const normalizedBasePath = normalizeTunnelBasePath(basePath); const resolvedDefaults: TunnelOptions = normalizedBasePath ? { ...rest, basePath: normalizedBasePath } : rest; From 332a7c1149d1cc12bc612f4adf7db46238a3c24e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:37:16 -0400 Subject: [PATCH 36/40] ref(core): Keep the defaults' base path in one place --- packages/core/src/utils/managedTunnelDefaults.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index 5c0724abdd40..6b775cce247f 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -14,9 +14,12 @@ import { * Managed tunnel settings a framework SDK knows outside `init`. */ interface ManagedTunnelDefaults { - /** Enables the managed tunnel, e.g. from TanStack Start's `tunnelRoute` build option. */ - tunnel?: ServerTunnelOptions; - /** The path prefix the app is served under. */ + /** + * 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. + */ + tunnel?: Omit; + /** The path prefix the app is served under. Applies however the tunnel was enabled, unless `init` set its own. */ basePath?: string; } @@ -44,7 +47,7 @@ export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void */ export function applyManagedTunnelDefaults(client: Client | undefined): void { const options = client?.getOptions(); - const tunnelOptions = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; + const tunnelOptions: ServerTunnelOptions | undefined = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; if (!client || !options || !tunnelOptions) { return; } From f1a81bfefac2f23dad6e1f6b76adb21d9d1b4b6f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:50:09 -0400 Subject: [PATCH 37/40] fix(core): Ignore a root fixed tunnel path instead of taking over POSTs to the root --- packages/core/src/utils/tunnel.ts | 6 ++++-- packages/core/test/lib/utils/tunnel.test.ts | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 90c5df1c9d64..eaa7dbee81ac 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -118,8 +118,10 @@ export function getTunnelPaths(): string[] { * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. */ export function resolveTunnelPaths(tunnelOptions: ServerTunnelOptions, allowedDsns: Array): string[] { - if (tunnelOptions.path) { - return [tunnelOptions.path.replace(/\/$/, '')]; + // A root path would take over every POST to the app's root, so it falls back to the DSN-derived ones. + const fixedPath = tunnelOptions.path?.replace(/\/$/, ''); + if (fixedPath) { + return [fixedPath]; } // Differently written DSNs can hash to the same path. diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 4127d8b16df8..57cc11aecdc7 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -520,6 +520,11 @@ describe('getTunnelPaths', () => { ); }); + it('ignores a root fixed path, so the tunnel does not take over POSTs to the root', () => { + expect(resolveTunnelPaths({ path: '/' }, [TEST_DSN])).toEqual([getTunnelPath(TEST_DSN)]); + expect(isTunnelRequestFor({ method: 'POST', url: '/' }, { path: '/', allowedDsns: [TEST_DSN] })).toBe(false); + }); + it('matches a fixed path that ends with a slash', () => { expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); expect(isTunnelRequestFor({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); From 98ff881f85db2c705274057c91fb21b263bdbdef Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 11:03:21 -0400 Subject: [PATCH 38/40] ref(core): Name the tunnel path hash `fnv1a32` and link its spec --- packages/core/src/utils/tunnel.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index eaa7dbee81ac..4987ff24e3d0 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -93,7 +93,7 @@ export function applyTunnelDefaults( export function getTunnelPath(dsn: DsnLike): string | undefined { const components = makeDsn(dsn); - return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + return components ? `/${fnv1a32(dsnToString(components)).toString(36)}` : undefined; } /** @@ -423,10 +423,11 @@ function stripBasePath(pathname: string, basePath: string | undefined): string | } /** - * 32-bit FNV-1a: synchronous (the browser needs it in `init`) and dependency free. Changing it breaks mixed SDK - * versions, since browser and server compute the path separately. + * 32-bit FNV-1a (https://www.ietf.org/archive/id/draft-eastlake-fnv-22.html#name-fnv32-code): synchronous (the + * browser needs it in `init`) and dependency free. Changing it breaks mixed SDK versions, since browser and server + * compute the path separately. */ -function fnv1a(input: string): number { +function fnv1a32(input: string): number { let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { // eslint-disable-next-line no-bitwise From 6023ac8da8dd7379b5bd44403c4d3fabbeedcd9c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 11:40:10 -0400 Subject: [PATCH 39/40] feat(core): Forward the client's user agent, client hints, Origin and Referer through the tunnel --- packages/core/src/utils/tunnel.ts | 21 +++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 29 +++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 4987ff24e3d0..0f0583d29fc4 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -308,6 +308,20 @@ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): s */ const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; +/** + * Client headers Relay reads from a browser's own request: the user agent and its client hints to tell the browser, + * and `Origin` or `Referer` for the project's allowed domains check, which Relay skips without them. + */ +const FORWARDED_CLIENT_HEADERS = [ + 'user-agent', + 'sec-ch-ua', + 'sec-ch-ua-model', + 'sec-ch-ua-platform', + 'sec-ch-ua-platform-version', + 'origin', + 'referer', +]; + /** * Reads the body up to the envelope header line. Returns the line and every byte read, or `undefined`. */ @@ -380,6 +394,13 @@ function forwardHeaders(request: Request): Record { headers['X-Sentry-Forwarded-For'] = clientIp; } + for (const name of FORWARDED_CLIENT_HEADERS) { + const value = request.headers.get(name); + if (value) { + headers[name] = value; + } + } + return headers; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 57cc11aecdc7..7870b9295f63 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -185,6 +185,35 @@ describe('handleTunnelRequest', () => { ); }); + it('forwards the client headers Relay reads the browser and allowed domains from', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const clientHeaders = { + 'user-agent': 'Mozilla/5.0', + 'sec-ch-ua': '"Chromium";v="140"', + 'sec-ch-ua-model': '""', + 'sec-ch-ua-platform': '"macOS"', + 'sec-ch-ua-platform-version': '"15.0.0"', + origin: 'https://example.com', + referer: 'https://example.com/page', + }; + + await handleTunnelRequest({ + request: 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], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', ...clientHeaders }, + }), + ); + }); + 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`), From b29bdaf599339a6f1ba0ca127b881d54b256ad48 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 11:44:38 -0400 Subject: [PATCH 40/40] ref(core): Drop the tunnel's body size cap and let Sentry's 413 decide --- packages/core/src/utils/tunnel.ts | 8 -------- packages/core/test/lib/utils/tunnel.test.ts | 20 +++++--------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 0f0583d29fc4..36f78d91f291 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -33,11 +33,6 @@ function getServedTunnel(): ServedTunnel | undefined { return client && servedTunnels.get(client); } -/** - * Bounds what one tunnel request may send. SDK envelopes stay far below it. - */ -export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; - export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; @@ -231,9 +226,6 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr if (!contentLength) { return new Response('Length required', { status: 411 }); } - if (contentLength > MAX_TUNNEL_BODY_SIZE) { - return new Response('Envelope too large', { status: 413 }); - } const reader = request.body?.getReader(); // A client aborting mid-upload makes the read throw. diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 7870b9295f63..33ce33115a3a 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -16,7 +16,6 @@ import { resolveAllowedDsns, resolveServerTunnelOption, resolveTunnelPaths, - MAX_TUNNEL_BODY_SIZE, } from '../../../src/utils/tunnel'; import { _INTERNAL_resetManagedTunnelDefaults, @@ -61,7 +60,7 @@ function makeEndlessRequest(head: string, onCancel?: () => void): Request { return new Request('http://localhost/tunnel', { method: 'POST', duplex: 'half', - headers: { 'content-length': String(MAX_TUNNEL_BODY_SIZE) }, + headers: { 'content-length': String(1024 * 1024 * 1024) }, body: new ReadableStream({ pull(controller) { controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); @@ -80,25 +79,16 @@ describe('handleTunnelRequest', () => { vi.stubGlobal('fetch', fetchMock); }); - it('forwards the envelope when Content-Length equals the limit', async () => { - fetchMock.mockResolvedValueOnce(new Response('ok')); + 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, MAX_TUNNEL_BODY_SIZE), + request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, 1024 * 1024 * 1024), allowedDsns: [TEST_DSN], }); - expect(result.status).toBe(200); - }); - - it('returns 413 when Content-Length is above the limit, without reading the body', async () => { - const request = makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE + 1); - - const result = await handleTunnelRequest({ request, allowedDsns: [TEST_DSN] }); - + expect(fetchMock).toHaveBeenCalledTimes(1); expect(result.status).toBe(413); - expect(request.bodyUsed).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 411 when the request has no Content-Length', async () => {