From 7cc3b00bc95f8e3d7ff6e22cae523da95c88d567 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:45:22 -0400 Subject: [PATCH 01/19] feat: Accept `tunnel: true` in `init` to enable the managed tunnel Setting `tunnel: true` (or `{ allowedDsns, basePath, trailingSlash }`) in both the browser and the server `init` enables the managed tunnel. The browser resolves it to the DSN-derived same-origin path and sends envelopes as `application/x-sentry-envelope`, because SvelteKit and Astro CSRF checks reject a same-origin `text/plain` POST. Server SDKs keep their own envelopes going directly to Sentry and set `_managedTunnel` for their request handlers. --- packages/aws-serverless/src/init.ts | 7 +- packages/browser/src/sdk.ts | 2 + packages/browser/src/utils/managedTunnel.ts | 38 ++++++++ .../browser/test/utils/managedTunnel.test.ts | 93 +++++++++++++++++++ packages/bun/src/client.ts | 3 +- packages/cloudflare/src/baseSdk.ts | 9 +- packages/cloudflare/test/sdk.test.ts | 9 ++ packages/core/src/types/options.ts | 17 +++- packages/deno/src/sdk.ts | 2 + packages/node/src/sdk/index.ts | 4 +- packages/node/test/sdk/init.test.ts | 18 ++++ packages/vercel-edge/src/sdk.ts | 4 +- 12 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 packages/browser/src/utils/managedTunnel.ts create mode 100644 packages/browser/test/utils/managedTunnel.test.ts diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 8c7c238265fa..f8e2869b9c3c 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -76,7 +76,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined // 2. Environment variable SENTRY_LAYER_EXTENSION (if set) // 3. Default logic based on sdkSource, tunnel, and proxy settings const useLayerExtensionFromEnv = envToBool(process.env.SENTRY_LAYER_EXTENSION, { strict: true }); - const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !options.tunnel && !proxyWouldInterfere; + const defaultUseLayerExtension = + sdkSource === 'aws-lambda-layer' && typeof options.tunnel !== 'string' && !proxyWouldInterfere; const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension; const opts = { @@ -88,8 +89,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined if (opts.useLayerExtension) { if (sdkSource !== 'aws-lambda-layer') { DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.'); - } else if (opts.tunnel || proxyWouldInterfere) { - if (opts.tunnel) { + } else if (typeof opts.tunnel === 'string' || proxyWouldInterfere) { + if (typeof opts.tunnel === 'string') { DEBUG_BUILD && debug.warn( `Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`, diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index c058144432e1..1425fe5c6829 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -24,6 +24,7 @@ import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { normalizeStringifyValue } from './normalizeStringifyValue'; import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension'; +import { resolveBrowserTunnelOption } from './utils/managedTunnel'; /** Get the default integrations for the browser SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -117,6 +118,7 @@ export function init(options: BrowserOptions = {}): Client | undefined { const clientOptions: BrowserClientOptions = { ...options, + ...resolveBrowserTunnelOption(options), enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations, diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts new file mode 100644 index 000000000000..fa394bde6da6 --- /dev/null +++ b/packages/browser/src/utils/managedTunnel.ts @@ -0,0 +1,38 @@ +import type { TunnelOptions } from '@sentry/core'; +import { debug, getTunnelPath } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import type { BrowserOptions } from '../client'; + +/** + * Resolves the `tunnel` option `init` received. `true` or {@link TunnelOptions} point the tunnel at the same-origin + * path the server SDK derives from the same DSN. + */ +export function resolveBrowserTunnelOption( + options: BrowserOptions, +): Pick & { tunnel: string | undefined } { + const { tunnel, dsn, transportOptions } = options; + if (typeof tunnel === 'string' || !tunnel) { + return { tunnel, transportOptions }; + } + + const tunnelOptions: TunnelOptions = tunnel === true ? {} : tunnel; + const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn)); + if (!tunnelPath) { + return { tunnel: undefined, transportOptions }; + } + + const basePath = tunnelOptions.basePath?.replace(/\/$/, '') ?? ''; + const resolvedTunnel = `${basePath}${tunnelPath}${tunnelOptions.trailingSlash ? '/' : ''}`; + + DEBUG_BUILD && debug.log(`Tunneling events to "${resolvedTunnel}"`); + + return { + tunnel: resolvedTunnel, + // The fetch transport sends `text/plain` by default to avoid a CORS preflight. A same-origin request has no + // preflight, and frameworks with CSRF protection (SvelteKit, Astro) reject a `text/plain` POST as a form submission. + transportOptions: { + ...transportOptions, + headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers }, + }, + }; +} diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts new file mode 100644 index 000000000000..a995520c9f47 --- /dev/null +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -0,0 +1,93 @@ +import type { EventEnvelope, EventItem } from '@sentry/core'; +import { createEnvelope, getTunnelPath, isTunnelRequest } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getClient, getCurrentScope, init } from '../../src'; +import { makeFetchTransport } from '../../src/transports/fetch'; +import { resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; + +const DSN = 'https://public@o123.ingest.sentry.io/1337'; + +describe('resolveBrowserTunnelOption', () => { + it('resolves `tunnel: true` to the path the server derives from the same DSN', () => { + const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: true }); + + expect(tunnel).toBe(getTunnelPath(DSN)); + expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN] })).toBe(true); + }); + + it('prefixes the base path and appends a trailing slash when asked', () => { + const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs/', trailingSlash: true } }); + + expect(tunnel).toBe(`/docs${getTunnelPath(DSN)}/`); + expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN], basePath: '/docs' })).toBe(true); + }); + + it('uses a fixed path instead of the DSN-derived one', () => { + const { tunnel, transportOptions } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }); + + expect(tunnel).toBe('/monitor'); + expect(transportOptions?.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); + }); + + it('keeps a URL tunnel and its transport options untouched', () => { + const transportOptions = { headers: { 'x-custom': '1' } }; + + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel', transportOptions })).toEqual({ + tunnel: '/my-tunnel', + transportOptions, + }); + }); + + it('turns the tunnel off without a valid DSN', () => { + expect(resolveBrowserTunnelOption({ tunnel: true }).tunnel).toBeUndefined(); + expect(resolveBrowserTunnelOption({ dsn: 'not-a-dsn', tunnel: true }).tunnel).toBeUndefined(); + }); + + it('sends envelopes as application/x-sentry-envelope so CSRF checks do not treat them as form posts', async () => { + const { tunnel, transportOptions } = resolveBrowserTunnelOption({ + dsn: DSN, + tunnel: true, + transportOptions: { headers: { 'x-custom': '1' } }, + }); + const nativeFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + const transport = makeFetchTransport( + { ...transportOptions, url: tunnel!, recordDroppedEvent: () => undefined }, + nativeFetch, + ); + await transport.send( + createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, + ]), + ); + + expect(nativeFetch).toHaveBeenCalledWith( + getTunnelPath(DSN), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', 'x-custom': '1' }, + }), + ); + }); + + it('lets a user-provided Content-Type header win', () => { + const { transportOptions } = resolveBrowserTunnelOption({ + dsn: DSN, + tunnel: true, + transportOptions: { headers: { 'Content-Type': 'text/plain' } }, + }); + + expect(transportOptions?.headers).toEqual({ 'Content-Type': 'text/plain' }); + }); +}); + +describe('init with `tunnel: true`', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('gives the client a resolved tunnel URL', () => { + init({ dsn: DSN, tunnel: true, defaultIntegrations: false }); + + expect(getClient()?.getOptions().tunnel).toBe(getTunnelPath(DSN)); + }); +}); diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index 03d301f05f95..e34942d78d79 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -1,6 +1,6 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; import { ServerRuntimeClient } from '@sentry/core/server'; -import { applySdkMetadata } from '@sentry/core'; +import { applySdkMetadata, resolveServerTunnelOption } from '@sentry/core'; import * as os from 'os'; import type { BunClientOptions } from './types'; @@ -17,6 +17,7 @@ export class BunClient extends ServerRuntimeClient { const clientOptions: ServerRuntimeClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), platform: 'javascript', runtime: { name: 'bun', version: Bun.version }, serverName: options.serverName || global.process.env.SENTRY_NAME || os.hostname(), diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..17e6069e70c7 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,5 +1,11 @@ import type { Integration } from '@sentry/core'; -import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { + _INTERNAL_isClientClosed, + debug, + getCurrentScope, + resolveServerTunnelOption, + setCurrentClient, +} from '@sentry/core'; import { consoleIntegration, conversationIdIntegration, @@ -120,6 +126,7 @@ export function initWithDefaultIntegrations( const clientOptions: CloudflareClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), cacheClient: cacheEnabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup(options), diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index dcbbb7089da4..dee64f789523 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -23,6 +23,15 @@ describe('init', () => { expect(client).toBeInstanceOf(CloudflareClient); }); + test('enables the managed tunnel for `tunnel: { allowedDsns }`', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: { allowedDsns: [browserDsn] } }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({ allowedDsns: [browserDsn] }); + }); + test('installs SpanStreaming integration by default', () => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 4f8bf39f117a..e1235e05c3a9 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,7 +374,7 @@ export interface ClientOptions = string | true /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, - 'integrations' | 'transport' | 'stackParser' + 'integrations' | 'transport' | 'stackParser' | 'tunnel' > { + /** + * Where to send envelopes instead of the Sentry ingest URL. + * + * - A URL sends envelopes to your own tunnel endpoint. On the server, a relative path such as `/monitor` instead + * serves the managed tunnel at that path, so `tunnel: '/monitor'` on both sides gives a fixed path. + * - `true` (or {@link TunnelOptions}) enables the managed tunnel. Set it in both the browser and the server `init`: + * the browser sends envelopes to a same-origin path derived from its DSN, and the server SDK serves that path and + * forwards the envelopes to Sentry. This keeps ad-blockers from dropping events. + * + * @default undefined + */ + tunnel?: string | true | TunnelOptions; + /** * If this is set to false, default integrations will not be added, otherwise this will internally be set to the * recommended default integrations. diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 96af20a57b87..6f8915650966 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -12,6 +12,7 @@ import { initAndBind, linkedErrorsIntegration, requestDataIntegration, + resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; import { detectOrchestrionSetup, getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; @@ -125,6 +126,7 @@ function _init(options: DenoOptions, getDefaultIntegrationsImpl: (options: Optio const clientOptions: ServerRuntimeClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup({ integrations: options.integrations, defaultIntegrations }), transport: options.transport || makeFetchTransport, diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 57cf3871256a..e40899c53b65 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -1,6 +1,5 @@ import type { Integration, Options } from '@sentry/core'; import { - _INTERNAL_warnIfClientIsActive, applySdkMetadata, consoleSandbox, conversationIdIntegration, @@ -12,9 +11,11 @@ import { getCurrentScope, getIntegrationsToSetup, hasSpansEnabled, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, + resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; import { isMainThread, parentPort } from 'node:worker_threads'; @@ -249,6 +250,7 @@ function getClientOptions( return { ...mergedOptions, + ...resolveServerTunnelOption(mergedOptions.tunnel), integrations: resolvedIntegrations, }; } diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 9ebfd0d05e31..7b92ef2d00af 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -40,6 +40,24 @@ describe('init()', () => { vi.clearAllMocks(); }); + describe('tunnel', () => { + it('enables the managed tunnel for `tunnel: true` and sends its own envelopes directly', () => { + init({ dsn: PUBLIC_DSN, tunnel: true }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({}); + }); + + it('keeps a URL tunnel for its own envelopes', () => { + init({ dsn: PUBLIC_DSN, tunnel: 'https://example.com/tunnel' }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBe('https://example.com/tunnel'); + expect(options?._managedTunnel).toBeUndefined(); + }); + }); + describe('metadata', () => { it('has the correct metadata', () => { init({ dsn: PUBLIC_DSN }); diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 47522657a510..494f0bf7e230 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,7 +1,6 @@ import { diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; import type { Client, Integration } from '@sentry/core'; import { - _INTERNAL_warnIfClientIsActive, consoleIntegration, conversationIdIntegration, createStackParser, @@ -13,8 +12,10 @@ import { getIntegrationsToSetup, getVercelEnv, GLOBAL_OBJ, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, requestDataIntegration, + resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; import { _INTERNAL_getReleaseFromCommitEnv as getReleaseFromCommitEnv, nodeStackLineParser } from '@sentry/core/server'; @@ -85,6 +86,7 @@ export function init(options: VercelEdgeOptions = {}): Client { const client = new VercelEdgeClient({ ...options, + ...resolveServerTunnelOption(options.tunnel), stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), integrations: getIntegrationsToSetup(options), transport: options.transport || makeEdgeTransport, From 49119249b3b0618d95413c427ed98c945494926a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:41:20 -0400 Subject: [PATCH 02/19] feat: Explain a managed tunnel that nothing on the server answers With `debug: true`, the client warns once when a managed tunnel request gets a 403, 404 or 405, pointing at the server side: a meta-framework SDK, an endpoint calling `handleTunnelRequest`, or `allowedDsns`. --- packages/browser/src/utils/managedTunnel.ts | 4 +- .../browser/test/utils/managedTunnel.test.ts | 7 ++++ packages/core/src/client.ts | 21 ++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 39 ++++++++++++++++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index fa394bde6da6..f7b83d5a6c8f 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -9,7 +9,7 @@ import type { BrowserOptions } from '../client'; */ export function resolveBrowserTunnelOption( options: BrowserOptions, -): Pick & { tunnel: string | undefined } { +): Pick & { tunnel: string | undefined } { const { tunnel, dsn, transportOptions } = options; if (typeof tunnel === 'string' || !tunnel) { return { tunnel, transportOptions }; @@ -28,6 +28,8 @@ export function resolveBrowserTunnelOption( return { tunnel: resolvedTunnel, + // Lets the client explain a tunnel request that nothing on the server answers. + _managedTunnel: tunnelOptions, // The fetch transport sends `text/plain` by default to avoid a CORS preflight. A same-origin request has no // preflight, and frameworks with CSRF protection (SvelteKit, Astro) reject a `text/plain` POST as a form submission. transportOptions: { diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index a995520c9f47..e6d7135c90e5 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -15,6 +15,13 @@ describe('resolveBrowserTunnelOption', () => { expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN] })).toBe(true); }); + it('marks the tunnel as managed so the client can explain a server that does not answer it', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs' } })._managedTunnel).toEqual({ + basePath: '/docs', + }); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel' })._managedTunnel).toBeUndefined(); + }); + it('prefixes the base path and appends a trailing slash when asked', () => { const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs/', trailingSlash: true } }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c61b3975d41b..c04ed986db0b 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -243,6 +243,9 @@ export abstract class Client { */ protected _unhandledSessionStatus: SessionStatus; + /** Whether we already explained a managed tunnel request the server rejected. */ + private _hasWarnedAboutManagedTunnel?: boolean; + /** * Initializes this client instance. * @@ -1317,6 +1320,8 @@ export abstract class Client { try { const result = await this._transport.send(envelope); this.emit('afterEnvelope', envelope); + DEBUG_BUILD && this._warnIfManagedTunnelIsNotServed(result.statusCode); + return result; } catch (reason) { DEBUG_BUILD && debug.error('Error while sending envelope:', reason); @@ -1328,6 +1333,22 @@ export abstract class Client { return {}; } + /** + * `tunnel: true` only works when the server serves the tunnel. These statuses mean nothing answers the path (404, 405) + * or something rejected the request (403), as opposed to Sentry rate limiting or failing. + */ + private _warnIfManagedTunnelIsNotServed(statusCode: number | undefined): void { + const { tunnel, _managedTunnel } = this._options; + if (!tunnel || !_managedTunnel || this._hasWarnedAboutManagedTunnel || ![403, 404, 405].includes(statusCode ?? 0)) { + return; + } + + this._hasWarnedAboutManagedTunnel = true; + debug.warn( + `The managed tunnel at "${tunnel}" responded with ${statusCode}, so events sent through it are lost. \`tunnel: true\` needs a server that answers it: a Sentry meta-framework SDK (SvelteKit, Nuxt, Astro, ...) or your own endpoint calling \`handleTunnelRequest\`. A 403 can also mean the server's \`allowedDsns\` doesn't include this DSN.`, + ); + } + /** * Register a cleanup function to be called when the client is disposed. * This is useful for integrations that need to clean up global state. diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 33ce33115a3a..016f2e6e92bc 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; -import { getCurrentScope, setCurrentClient } from '../../../src'; +import { createTransport, debug, getCurrentScope, resolvedSyncPromise, setCurrentClient } from '../../../src'; import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; @@ -703,6 +703,43 @@ describe('server client with the managed tunnel enabled', () => { }); }); +describe('client warning about a managed tunnel nothing serves', () => { + /** The tunnel warnings among the debug warnings, as the transport also warns about every failed response. */ + async function tunnelWarningsFor(statusCode: number, managedTunnel?: TunnelOptions): Promise { + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: TEST_DSN, + tunnel: '/abc123', + _managedTunnel: managedTunnel, + transport: () => + createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({ statusCode })), + }), + ); + + await client.sendEnvelope(createEnvelope({}, [[{ type: 'event' }, {}]] as never)); + await client.sendEnvelope(createEnvelope({}, [[{ type: 'event' }, {}]] as never)); + + return warnSpy.mock.calls.flat().filter(message => String(message).includes('managed tunnel')); + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('warns once when the server does not answer the managed tunnel', async () => { + const warnings = await tunnelWarningsFor(404, {}); + + expect(warnings).toEqual([expect.stringContaining('The managed tunnel at "/abc123" responded with 404')]); + }); + + it('stays quiet for rate limits and for tunnels the user set up themselves', async () => { + expect(await tunnelWarningsFor(429, {})).toEqual([]); + vi.restoreAllMocks(); + expect(await tunnelWarningsFor(404)).toEqual([]); + }); +}); + describe('resolveServerTunnelOption', () => { it('keeps a URL as the transport tunnel', () => { expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); From d138861afdb2047f8f9e080db90fd372f3246661 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:39:20 -0400 Subject: [PATCH 03/19] fix(core): Keep the managed tunnel warning out of production bundles --- packages/core/src/client.ts | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c04ed986db0b..019a09cdb502 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -244,7 +244,6 @@ export abstract class Client { protected _unhandledSessionStatus: SessionStatus; /** Whether we already explained a managed tunnel request the server rejected. */ - private _hasWarnedAboutManagedTunnel?: boolean; /** * Initializes this client instance. @@ -1320,7 +1319,7 @@ export abstract class Client { try { const result = await this._transport.send(envelope); this.emit('afterEnvelope', envelope); - DEBUG_BUILD && this._warnIfManagedTunnelIsNotServed(result.statusCode); + DEBUG_BUILD && warnIfManagedTunnelIsNotServed(this._options, result.statusCode); return result; } catch (reason) { @@ -1333,22 +1332,6 @@ export abstract class Client { return {}; } - /** - * `tunnel: true` only works when the server serves the tunnel. These statuses mean nothing answers the path (404, 405) - * or something rejected the request (403), as opposed to Sentry rate limiting or failing. - */ - private _warnIfManagedTunnelIsNotServed(statusCode: number | undefined): void { - const { tunnel, _managedTunnel } = this._options; - if (!tunnel || !_managedTunnel || this._hasWarnedAboutManagedTunnel || ![403, 404, 405].includes(statusCode ?? 0)) { - return; - } - - this._hasWarnedAboutManagedTunnel = true; - debug.warn( - `The managed tunnel at "${tunnel}" responded with ${statusCode}, so events sent through it are lost. \`tunnel: true\` needs a server that answers it: a Sentry meta-framework SDK (SvelteKit, Nuxt, Astro, ...) or your own endpoint calling \`handleTunnelRequest\`. A 403 can also mean the server's \`allowedDsns\` doesn't include this DSN.`, - ); - } - /** * Register a cleanup function to be called when the client is disposed. * This is useful for integrations that need to clean up global state. @@ -1946,3 +1929,21 @@ function estimatePrimitiveSizeInBytes(value: Primitive): number { return 0; } + +let hasWarnedAboutManagedTunnel = false; + +/** + * `tunnel: true` only works when the server serves the tunnel. These statuses mean nothing answers the path (404, 405) + * or something rejected the request (403), as opposed to Sentry rate limiting or failing. + */ +function warnIfManagedTunnelIsNotServed(options: ClientOptions, statusCode: number | undefined): void { + const { tunnel, _managedTunnel } = options; + if (!tunnel || !_managedTunnel || hasWarnedAboutManagedTunnel || ![403, 404, 405].includes(statusCode ?? 0)) { + return; + } + + hasWarnedAboutManagedTunnel = true; + debug.warn( + `The managed tunnel at "${tunnel}" responded with ${statusCode}, so events sent through it are lost. \`tunnel: true\` needs a server that answers it: a Sentry meta-framework SDK (SvelteKit, Nuxt, Astro, ...) or your own endpoint calling \`handleTunnelRequest\`. A 403 can also mean the server's \`allowedDsns\` doesn't include this DSN.`, + ); +} From f651a977c7405ba2ebc48fb87f93dd44cd5aaf90 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 22:22:08 -0400 Subject: [PATCH 04/19] fix(aws-serverless): Keep the managed tunnel when the Lambda extension sends events --- packages/aws-serverless/src/init.ts | 4 +++- packages/aws-serverless/test/init.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index f8e2869b9c3c..22745671ecb3 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,5 +1,5 @@ import type { Integration, Options } from '@sentry/core'; -import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core'; +import { applySdkMetadata, debug, envToBool, getSDKSource, resolveServerTunnelOption } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; import { awsIntegration } from '@sentry/server-utils'; @@ -105,6 +105,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined } } else { DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension'); + // The extension takes over the transport, but `tunnel: true` still has to serve the managed tunnel. + opts._managedTunnel = resolveServerTunnelOption(opts.tunnel)._managedTunnel; opts.tunnel = 'http://localhost:9000/envelope'; } } diff --git a/packages/aws-serverless/test/init.test.ts b/packages/aws-serverless/test/init.test.ts index 500338dc7144..a7eda1bcfe84 100644 --- a/packages/aws-serverless/test/init.test.ts +++ b/packages/aws-serverless/test/init.test.ts @@ -60,6 +60,19 @@ describe('init', () => { ); }); + test('keeps the managed tunnel when the Lambda extension takes over the transport', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + + init({ tunnel: true, useLayerExtension: true }); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.objectContaining({ + tunnel: 'http://localhost:9000/envelope', + _managedTunnel: {}, + }), + ); + }); + test('should not set tunnel when Lambda extension is disabled', () => { mockGetSDKSource.mockReturnValue('aws-lambda-layer'); const options: AwsServerlessOptions = { From 36c4a1af6420ed097e3c651b8b7dd222a12a8527 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:45:46 -0400 Subject: [PATCH 05/19] fix(core): Point Next.js users to `tunnelRoute` when the managed tunnel isn't served --- packages/core/src/client.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 019a09cdb502..f89a3ec362e8 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -243,8 +243,6 @@ export abstract class Client { */ protected _unhandledSessionStatus: SessionStatus; - /** Whether we already explained a managed tunnel request the server rejected. */ - /** * Initializes this client instance. * @@ -1944,6 +1942,6 @@ function warnIfManagedTunnelIsNotServed(options: ClientOptions, statusCode: numb hasWarnedAboutManagedTunnel = true; debug.warn( - `The managed tunnel at "${tunnel}" responded with ${statusCode}, so events sent through it are lost. \`tunnel: true\` needs a server that answers it: a Sentry meta-framework SDK (SvelteKit, Nuxt, Astro, ...) or your own endpoint calling \`handleTunnelRequest\`. A 403 can also mean the server's \`allowedDsns\` doesn't include this DSN.`, + `The managed tunnel at "${tunnel}" responded with ${statusCode}, so events sent through it are lost. \`tunnel: true\` needs a server that answers it: a Sentry meta-framework SDK (SvelteKit, Nuxt, Astro, ...), \`tunnelRoute\` in Next.js, or your own endpoint calling \`handleTunnelRequest\`. A 403 can also mean the server's \`allowedDsns\` doesn't include this DSN.`, ); } From 5d4afe94872fe96af244e84af20a62aec8920a7f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:15:26 -0400 Subject: [PATCH 06/19] feat(browser): Send directly to Sentry when nothing answers the managed tunnel --- packages/browser/src/sdk.ts | 11 +- packages/browser/src/transports/fetch.ts | 2 + packages/browser/src/utils/managedTunnel.ts | 71 +++++++++- .../browser/test/transports/fetch.test.ts | 3 +- .../browser/test/utils/managedTunnel.test.ts | 124 +++++++++++++++++- 5 files changed, 202 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 1425fe5c6829..8c0046d69e1b 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -24,7 +24,7 @@ import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { normalizeStringifyValue } from './normalizeStringifyValue'; import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension'; -import { resolveBrowserTunnelOption } from './utils/managedTunnel'; +import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from './utils/managedTunnel'; /** Get the default integrations for the browser SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -116,13 +116,18 @@ export function init(options: BrowserOptions = {}): Client | undefined { defaultIntegrations, }); + const tunnelOptions = resolveBrowserTunnelOption(options); + const transport = options.transport || makeFetchTransport; + const clientOptions: BrowserClientOptions = { ...options, - ...resolveBrowserTunnelOption(options), + ...tunnelOptions, enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations, - transport: options.transport || makeFetchTransport, + transport: tunnelOptions._managedTunnel + ? makeManagedTunnelTransport(transport, options.transportOptions?.headers) + : transport, }; setNormalizeStringifier(normalizeStringifyValue); diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 83e3905de35c..cafaac5ea23f 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -50,6 +50,8 @@ export function makeFetchTransport( headers: { 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), 'retry-after': response.headers.get('Retry-After'), + // Tells a managed tunnel's own responses apart from whatever else answers its path. + 'x-sentry-tunnel': response.headers.get('X-Sentry-Tunnel'), }, }; } catch (e) { diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index f7b83d5a6c8f..39542b04c54a 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -1,7 +1,8 @@ -import type { TunnelOptions } from '@sentry/core'; -import { debug, getTunnelPath } from '@sentry/core'; +import type { Envelope, Transport, TransportMakeRequestResponse, TunnelOptions } from '@sentry/core'; +import { debug, getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import type { BrowserOptions } from '../client'; +import type { BrowserTransportOptions } from '../transports/types'; /** * Resolves the `tunnel` option `init` received. `true` or {@link TunnelOptions} point the tunnel at the same-origin @@ -38,3 +39,69 @@ export function resolveBrowserTunnelOption( }, }; } + +/** + * Sends to Sentry directly once it's clear nothing on this origin serves the managed tunnel, e.g. in a static build, + * on an SPA host, or when the server SDK isn't set up. An envelope is only sent again when the tunnel's response + * proves it was never ingested, so nothing reaches Sentry twice. + */ +export function makeManagedTunnelTransport( + makeTransport: (options: BrowserTransportOptions) => Transport, + directHeaders: BrowserTransportOptions['headers'], +): (options: BrowserTransportOptions) => Transport { + return options => { + const tunnelTransport = makeTransport(options); + let directTransport: Transport | undefined; + + const switchToDirect = (envelope: Envelope, reason: string): Transport | undefined => { + const { dsn, sdk } = envelope[0]; + const components = dsn && makeDsn(dsn); + if (components && !directTransport) { + DEBUG_BUILD && debug.warn(`Sending to Sentry directly: ${reason}`); + directTransport = makeTransport({ + ...options, + url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk), + headers: directHeaders, + }); + } + + return directTransport; + }; + + return { + async send(envelope: Envelope): Promise { + if (directTransport) { + return directTransport.send(envelope); + } + + const response = await tunnelTransport.send(envelope); + const { statusCode } = response; + + // Nothing handles the tunnel path, so the envelope never reached Sentry and is safe to send once more. + if (statusCode === 404 || statusCode === 405) { + const transport = switchToDirect(envelope, `nothing answers the managed tunnel at "${options.url}".`); + + return transport ? transport.send(envelope) : response; + } + + // Something other than the tunnel handler answered, like a static host serving `index.html`. This envelope + // may have been delivered all the same, so it isn't sent again, only the ones after it go directly. + if ( + statusCode !== undefined && + statusCode >= 200 && + statusCode < 300 && + !response.headers?.['x-sentry-tunnel'] + ) { + switchToDirect(envelope, `the response from "${options.url}" didn't come from a Sentry tunnel handler.`); + } + + return response; + }, + async flush(timeout?: number): Promise { + const results = await Promise.all([tunnelTransport.flush(timeout), directTransport?.flush(timeout) ?? true]); + + return results.every(Boolean); + }, + }; + }; +} diff --git a/packages/browser/test/transports/fetch.test.ts b/packages/browser/test/transports/fetch.test.ts index 1c1dee2c7f90..52098701347d 100644 --- a/packages/browser/test/transports/fetch.test.ts +++ b/packages/browser/test/transports/fetch.test.ts @@ -73,9 +73,10 @@ describe('fetchTransport', () => { expect(headers.get).toHaveBeenCalledTimes(0); await transport.send(ERROR_ENVELOPE); - expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledTimes(3); expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); expect(headers.get).toHaveBeenCalledWith('Retry-After'); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Tunnel'); }); it('allows for custom options to be passed in', async () => { diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index e6d7135c90e5..eb375985f4f1 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -1,9 +1,16 @@ -import type { EventEnvelope, EventItem } from '@sentry/core'; -import { createEnvelope, getTunnelPath, isTunnelRequest } from '@sentry/core'; +import type { EventEnvelope, EventItem, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import { + createEnvelope, + getEnvelopeEndpointWithUrlEncodedAuth, + getTunnelPath, + isTunnelRequest, + makeDsn, +} from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getClient, getCurrentScope, init } from '../../src'; import { makeFetchTransport } from '../../src/transports/fetch'; -import { resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; +import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; +import type { BrowserTransportOptions } from '../../src/transports/types'; const DSN = 'https://public@o123.ingest.sentry.io/1337'; @@ -98,3 +105,114 @@ describe('init with `tunnel: true`', () => { expect(getClient()?.getOptions().tunnel).toBe(getTunnelPath(DSN)); }); }); + +describe('makeManagedTunnelTransport', () => { + const TUNNEL_URL = `https://example.com${getTunnelPath(DSN)}`; + const DIRECT_URL = getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(DSN)!); + + function makeEnvelope(id: string): EventEnvelope { + return createEnvelope({ dsn: DSN, event_id: id }, [ + [{ type: 'event' }, { event_id: id }] as EventItem, + ]); + } + + // Each transport answers with the next response queued for its URL and records what it sent. + function setup(responses: Record): { + transport: Transport; + sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }>; + } { + const sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }> = []; + const makeTransport = (options: BrowserTransportOptions): Transport => ({ + send: envelope => { + sent.push({ url: options.url, id: String(envelope[0].event_id), headers: options.headers }); + + return Promise.resolve(responses[options.url]?.shift() ?? { statusCode: 200 }); + }, + flush: () => Promise.resolve(true), + }); + const transport = makeManagedTunnelTransport(makeTransport, { 'X-Custom': '1' })({ + url: TUNNEL_URL, + headers: { 'Content-Type': 'application/x-sentry-envelope', 'X-Custom': '1' }, + recordDroppedEvent: () => undefined, + }); + + return { transport, sent }; + } + + const tunnelResponse = { statusCode: 200, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; + + it('keeps sending through the tunnel while its handler answers', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [tunnelResponse, tunnelResponse] }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + + it.each([404, 405])( + 'sends an envelope directly once, and everything after it, when the tunnel answers %s', + async statusCode => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + + expect(await transport.send(makeEnvelope('a'))).toEqual({ statusCode: 200 }); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [DIRECT_URL, 'a'], + [DIRECT_URL, 'b'], + ]); + // Sentry takes the default `text/plain` without a CORS preflight, so the tunnel's content type is dropped. + expect(sent[1]?.headers).toEqual({ 'X-Custom': '1' }); + }, + ); + + it('never sends an envelope again after a 2xx the tunnel handler did not produce, only the ones after it', async () => { + const { transport, sent } = setup({ + [TUNNEL_URL]: [{ statusCode: 200, headers: {} } as TransportMakeRequestResponse], + }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [DIRECT_URL, 'b'], + ]); + }); + + it.each([400, 403, 413, 429, 500, 503])('never sends an envelope again after a %s', async statusCode => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + + it('only sends each envelope directly once when several were already on their way to the tunnel', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode: 404 }, { statusCode: 404 }] }); + + await Promise.all([transport.send(makeEnvelope('a')), transport.send(makeEnvelope('b'))]); + + expect(sent.filter(({ url }) => url === DIRECT_URL).map(({ id }) => id)).toEqual(['a', 'b']); + }); + + it('is used by `init` for a managed tunnel only', () => { + const transport = vi.fn(makeFetchTransport); + + init({ dsn: DSN, tunnel: true, transport }); + const managedTransportOptions = transport.mock.calls[0]?.[0]; + init({ dsn: DSN, tunnel: 'https://proxy.example.com', transport }); + + expect(managedTransportOptions?.url).toBe(getTunnelPath(DSN)); + expect(transport).toHaveBeenLastCalledWith(expect.objectContaining({ url: 'https://proxy.example.com' })); + }); +}); From 5ebb8e9a90baf9c205033502b16beb2bceb70572 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:45:03 -0400 Subject: [PATCH 07/19] fix(browser): Normalize the managed tunnel's base path like the server does --- packages/browser/src/utils/managedTunnel.ts | 10 ++++++++-- packages/browser/test/utils/managedTunnel.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index 39542b04c54a..9183bf9d1215 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -1,5 +1,11 @@ import type { Envelope, Transport, TransportMakeRequestResponse, TunnelOptions } from '@sentry/core'; -import { debug, getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn } from '@sentry/core'; +import { + debug, + getEnvelopeEndpointWithUrlEncodedAuth, + getTunnelPath, + makeDsn, + normalizeTunnelBasePath, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import type { BrowserOptions } from '../client'; import type { BrowserTransportOptions } from '../transports/types'; @@ -22,7 +28,7 @@ export function resolveBrowserTunnelOption( return { tunnel: undefined, transportOptions }; } - const basePath = tunnelOptions.basePath?.replace(/\/$/, '') ?? ''; + const basePath = normalizeTunnelBasePath(tunnelOptions.basePath); const resolvedTunnel = `${basePath}${tunnelPath}${tunnelOptions.trailingSlash ? '/' : ''}`; DEBUG_BUILD && debug.log(`Tunneling events to "${resolvedTunnel}"`); diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index eb375985f4f1..c00e93f7bad2 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -36,6 +36,16 @@ describe('resolveBrowserTunnelOption', () => { expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN], basePath: '/docs' })).toBe(true); }); + it('normalizes the base path like the server does, so the tunnel URL is never page-relative', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: 'docs' } }).tunnel).toBe( + `/docs${getTunnelPath(DSN)}`, + ); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: './docs/' } }).tunnel).toBe( + `/docs${getTunnelPath(DSN)}`, + ); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: './' } }).tunnel).toBe(getTunnelPath(DSN)); + }); + it('uses a fixed path instead of the DSN-derived one', () => { const { tunnel, transportOptions } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }); From 1f23d5abc389dfce8d0df88081333301f0fd9f44 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:53:23 -0400 Subject: [PATCH 08/19] fix(browser): Keep the managed tunnel when Sentry answers 404 or 405 through it --- packages/browser/src/utils/managedTunnel.ts | 12 ++++++------ packages/browser/test/utils/managedTunnel.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index 9183bf9d1215..01018d0da521 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -83,6 +83,11 @@ export function makeManagedTunnelTransport( const response = await tunnelTransport.send(envelope); const { statusCode } = response; + // The tunnel handler answered, so whatever status it passes on is Sentry's. + if (response.headers?.['x-sentry-tunnel']) { + return response; + } + // Nothing handles the tunnel path, so the envelope never reached Sentry and is safe to send once more. if (statusCode === 404 || statusCode === 405) { const transport = switchToDirect(envelope, `nothing answers the managed tunnel at "${options.url}".`); @@ -92,12 +97,7 @@ export function makeManagedTunnelTransport( // Something other than the tunnel handler answered, like a static host serving `index.html`. This envelope // may have been delivered all the same, so it isn't sent again, only the ones after it go directly. - if ( - statusCode !== undefined && - statusCode >= 200 && - statusCode < 300 && - !response.headers?.['x-sentry-tunnel'] - ) { + if (statusCode !== undefined && statusCode >= 200 && statusCode < 300) { switchToDirect(envelope, `the response from "${options.url}" didn't come from a Sentry tunnel handler.`); } diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index c00e93f7bad2..9cfc0bdaeb0c 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -181,6 +181,19 @@ describe('makeManagedTunnelTransport', () => { }, ); + it.each([404, 405])('keeps using the tunnel when Sentry answers %s through its handler', async statusCode => { + const sentryResponse = { statusCode, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; + const { transport, sent } = setup({ [TUNNEL_URL]: [sentryResponse, tunnelResponse] }); + + expect(await transport.send(makeEnvelope('a'))).toBe(sentryResponse); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + it('never sends an envelope again after a 2xx the tunnel handler did not produce, only the ones after it', async () => { const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode: 200, headers: {} } as TransportMakeRequestResponse], From 36dfaab0e51a14c9988d80596c73bb2b94527c2d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:06:53 -0400 Subject: [PATCH 09/19] fix(browser): Send directly when anything but the tunnel answers a managed tunnel request with a 4xx --- packages/browser/src/utils/managedTunnel.ts | 8 +++-- .../browser/test/utils/managedTunnel.test.ts | 29 ++++++++++--------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index 01018d0da521..b1f291a2c04c 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -88,9 +88,11 @@ export function makeManagedTunnelTransport( return response; } - // Nothing handles the tunnel path, so the envelope never reached Sentry and is safe to send once more. - if (statusCode === 404 || statusCode === 405) { - const transport = switchToDirect(envelope, `nothing answers the managed tunnel at "${options.url}".`); + // Something other than the tunnel handler rejected the request, e.g. nothing serves the path (404) or an auth + // middleware answered first (401). Only the handler forwards, so the envelope never reached Sentry and is safe + // to send once more. + if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) { + const transport = switchToDirect(envelope, `"${options.url}" answered ${statusCode} instead of the tunnel.`); return transport ? transport.send(envelope) : response; } diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index 9cfc0bdaeb0c..d56df22261c2 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -163,8 +163,8 @@ describe('makeManagedTunnelTransport', () => { ]); }); - it.each([404, 405])( - 'sends an envelope directly once, and everything after it, when the tunnel answers %s', + it.each([400, 401, 403, 404, 405, 413, 429])( + 'sends an envelope directly once, and everything after it, when something other than the tunnel answers %s', async statusCode => { const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); @@ -181,18 +181,21 @@ describe('makeManagedTunnelTransport', () => { }, ); - it.each([404, 405])('keeps using the tunnel when Sentry answers %s through its handler', async statusCode => { - const sentryResponse = { statusCode, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; - const { transport, sent } = setup({ [TUNNEL_URL]: [sentryResponse, tunnelResponse] }); + it.each([403, 404, 405, 429])( + 'keeps using the tunnel when Sentry answers %s through its handler', + async statusCode => { + const sentryResponse = { statusCode, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; + const { transport, sent } = setup({ [TUNNEL_URL]: [sentryResponse, tunnelResponse] }); - expect(await transport.send(makeEnvelope('a'))).toBe(sentryResponse); - await transport.send(makeEnvelope('b')); + expect(await transport.send(makeEnvelope('a'))).toBe(sentryResponse); + await transport.send(makeEnvelope('b')); - expect(sent.map(({ url, id }) => [url, id])).toEqual([ - [TUNNEL_URL, 'a'], - [TUNNEL_URL, 'b'], - ]); - }); + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }, + ); it('never sends an envelope again after a 2xx the tunnel handler did not produce, only the ones after it', async () => { const { transport, sent } = setup({ @@ -208,7 +211,7 @@ describe('makeManagedTunnelTransport', () => { ]); }); - it.each([400, 403, 413, 429, 500, 503])('never sends an envelope again after a %s', async statusCode => { + it.each([500, 503])('never sends an envelope again after a %s', async statusCode => { const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); await transport.send(makeEnvelope('a')); From f0b532c812d337333e2dafb2237458bcb8a9751c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:56:10 -0400 Subject: [PATCH 10/19] ref(browser): Warn about a rejected DSN from the managed tunnel transport, and import server helpers from core/server Only the browser sends to a managed tunnel, so the warning moves from the shared Client into the browser's tunnel transport. Server SDKs import resolveServerTunnelOption from @sentry/core/server. --- packages/aws-serverless/src/init.ts | 3 +- packages/browser/src/utils/managedTunnel.ts | 9 +++++ .../browser/test/utils/managedTunnel.test.ts | 9 +---- packages/bun/src/client.ts | 4 +- packages/cloudflare/src/baseSdk.ts | 9 +---- packages/core/src/client.ts | 19 --------- packages/core/test/lib/utils/tunnel.test.ts | 39 +------------------ packages/deno/src/sdk.ts | 3 +- packages/node/src/sdk/index.ts | 2 +- packages/vercel-edge/src/sdk.ts | 7 +++- 10 files changed, 25 insertions(+), 79 deletions(-) diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 22745671ecb3..48bbceef2f96 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,5 +1,6 @@ import type { Integration, Options } from '@sentry/core'; -import { applySdkMetadata, debug, envToBool, getSDKSource, resolveServerTunnelOption } from '@sentry/core'; +import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; import { awsIntegration } from '@sentry/server-utils'; diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index b1f291a2c04c..317ab2acb426 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -46,6 +46,8 @@ export function resolveBrowserTunnelOption( }; } +let hasWarnedAboutRejectedDsn = false; + /** * Sends to Sentry directly once it's clear nothing on this origin serves the managed tunnel, e.g. in a static build, * on an SPA host, or when the server SDK isn't set up. An envelope is only sent again when the tunnel's response @@ -85,6 +87,13 @@ export function makeManagedTunnelTransport( // The tunnel handler answered, so whatever status it passes on is Sentry's. if (response.headers?.['x-sentry-tunnel']) { + if (DEBUG_BUILD && statusCode === 403 && !hasWarnedAboutRejectedDsn) { + hasWarnedAboutRejectedDsn = true; + debug.warn( + `The managed tunnel at "${options.url}" rejected this DSN, so events sent through it are lost. Add it to the server's \`allowedDsns\`.`, + ); + } + return response; } diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index d56df22261c2..dc366af408d5 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -1,11 +1,6 @@ import type { EventEnvelope, EventItem, Transport, TransportMakeRequestResponse } from '@sentry/core'; -import { - createEnvelope, - getEnvelopeEndpointWithUrlEncodedAuth, - getTunnelPath, - isTunnelRequest, - makeDsn, -} from '@sentry/core'; +import { createEnvelope, getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn } from '@sentry/core'; +import { isTunnelRequest } from '@sentry/core/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getClient, getCurrentScope, init } from '../../src'; import { makeFetchTransport } from '../../src/transports/fetch'; diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index e34942d78d79..b4670612e1e0 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -1,6 +1,6 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; -import { ServerRuntimeClient } from '@sentry/core/server'; -import { applySdkMetadata, resolveServerTunnelOption } from '@sentry/core'; +import { resolveServerTunnelOption, ServerRuntimeClient } from '@sentry/core/server'; +import { applySdkMetadata } from '@sentry/core'; import * as os from 'os'; import type { BunClientOptions } from './types'; diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 17e6069e70c7..fdd0f3c89a81 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,11 +1,6 @@ import type { Integration } from '@sentry/core'; -import { - _INTERNAL_isClientClosed, - debug, - getCurrentScope, - resolveServerTunnelOption, - setCurrentClient, -} from '@sentry/core'; +import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import { consoleIntegration, conversationIdIntegration, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index f89a3ec362e8..a07e32769da6 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1317,7 +1317,6 @@ export abstract class Client { try { const result = await this._transport.send(envelope); this.emit('afterEnvelope', envelope); - DEBUG_BUILD && warnIfManagedTunnelIsNotServed(this._options, result.statusCode); return result; } catch (reason) { @@ -1927,21 +1926,3 @@ function estimatePrimitiveSizeInBytes(value: Primitive): number { return 0; } - -let hasWarnedAboutManagedTunnel = false; - -/** - * `tunnel: true` only works when the server serves the tunnel. These statuses mean nothing answers the path (404, 405) - * or something rejected the request (403), as opposed to Sentry rate limiting or failing. - */ -function warnIfManagedTunnelIsNotServed(options: ClientOptions, statusCode: number | undefined): void { - const { tunnel, _managedTunnel } = options; - if (!tunnel || !_managedTunnel || hasWarnedAboutManagedTunnel || ![403, 404, 405].includes(statusCode ?? 0)) { - return; - } - - hasWarnedAboutManagedTunnel = true; - debug.warn( - `The managed tunnel at "${tunnel}" responded with ${statusCode}, so events sent through it are lost. \`tunnel: true\` needs a server that answers it: a Sentry meta-framework SDK (SvelteKit, Nuxt, Astro, ...), \`tunnelRoute\` in Next.js, or your own endpoint calling \`handleTunnelRequest\`. A 403 can also mean the server's \`allowedDsns\` doesn't include this DSN.`, - ); -} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 016f2e6e92bc..33ce33115a3a 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; -import { createTransport, debug, getCurrentScope, resolvedSyncPromise, setCurrentClient } from '../../../src'; +import { getCurrentScope, setCurrentClient } from '../../../src'; import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; @@ -703,43 +703,6 @@ describe('server client with the managed tunnel enabled', () => { }); }); -describe('client warning about a managed tunnel nothing serves', () => { - /** The tunnel warnings among the debug warnings, as the transport also warns about every failed response. */ - async function tunnelWarningsFor(statusCode: number, managedTunnel?: TunnelOptions): Promise { - const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: TEST_DSN, - tunnel: '/abc123', - _managedTunnel: managedTunnel, - transport: () => - createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({ statusCode })), - }), - ); - - await client.sendEnvelope(createEnvelope({}, [[{ type: 'event' }, {}]] as never)); - await client.sendEnvelope(createEnvelope({}, [[{ type: 'event' }, {}]] as never)); - - return warnSpy.mock.calls.flat().filter(message => String(message).includes('managed tunnel')); - } - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('warns once when the server does not answer the managed tunnel', async () => { - const warnings = await tunnelWarningsFor(404, {}); - - expect(warnings).toEqual([expect.stringContaining('The managed tunnel at "/abc123" responded with 404')]); - }); - - it('stays quiet for rate limits and for tunnels the user set up themselves', async () => { - expect(await tunnelWarningsFor(429, {})).toEqual([]); - vi.restoreAllMocks(); - expect(await tunnelWarningsFor(404)).toEqual([]); - }); -}); - describe('resolveServerTunnelOption', () => { it('keeps a URL as the transport tunnel', () => { expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 6f8915650966..0ccb10cccf24 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -12,12 +12,11 @@ import { initAndBind, linkedErrorsIntegration, requestDataIntegration, - resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; +import { nodeStackLineParser, resolveServerTunnelOption } from '@sentry/core/server'; import { detectOrchestrionSetup, getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; import { DenoClient } from './client'; -import { nodeStackLineParser } from '@sentry/core/server'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; import { denoContextIntegration } from './integrations/context'; import { contextLinesIntegration } from './integrations/contextlines'; diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index e40899c53b65..3367653fb076 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -15,9 +15,9 @@ import { linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, - resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import { isMainThread, parentPort } from 'node:worker_threads'; import { detectOrchestrionSetup, getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; import { DEBUG_BUILD } from '../debug-build'; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 494f0bf7e230..b2289ef5c1e5 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -15,10 +15,13 @@ import { _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, requestDataIntegration, - resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; -import { _INTERNAL_getReleaseFromCommitEnv as getReleaseFromCommitEnv, nodeStackLineParser } from '@sentry/core/server'; +import { + _INTERNAL_getReleaseFromCommitEnv as getReleaseFromCommitEnv, + nodeStackLineParser, + resolveServerTunnelOption, +} from '@sentry/core/server'; import { SentryPropagator, SentryTracerProvider, From 6f1d9c9fd03be89673cfd666835888ede5e51de3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:15:01 -0400 Subject: [PATCH 11/19] ref(browser): Switch the managed tunnel to direct sends in one place The reason strings were built in production bundles too, while only the debug warning that printed them was stripped. --- packages/browser/src/utils/managedTunnel.ts | 47 ++++++++------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index 317ab2acb426..c91f03131d72 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -61,21 +61,6 @@ export function makeManagedTunnelTransport( const tunnelTransport = makeTransport(options); let directTransport: Transport | undefined; - const switchToDirect = (envelope: Envelope, reason: string): Transport | undefined => { - const { dsn, sdk } = envelope[0]; - const components = dsn && makeDsn(dsn); - if (components && !directTransport) { - DEBUG_BUILD && debug.warn(`Sending to Sentry directly: ${reason}`); - directTransport = makeTransport({ - ...options, - url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk), - headers: directHeaders, - }); - } - - return directTransport; - }; - return { async send(envelope: Envelope): Promise { if (directTransport) { @@ -83,7 +68,7 @@ export function makeManagedTunnelTransport( } const response = await tunnelTransport.send(envelope); - const { statusCode } = response; + const statusCode = response.statusCode || 0; // The tunnel handler answered, so whatever status it passes on is Sentry's. if (response.headers?.['x-sentry-tunnel']) { @@ -97,22 +82,26 @@ export function makeManagedTunnelTransport( return response; } - // Something other than the tunnel handler rejected the request, e.g. nothing serves the path (404) or an auth - // middleware answered first (401). Only the handler forwards, so the envelope never reached Sentry and is safe - // to send once more. - if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) { - const transport = switchToDirect(envelope, `"${options.url}" answered ${statusCode} instead of the tunnel.`); - - return transport ? transport.send(envelope) : response; + // Something other than the tunnel handler answered. A 4xx (nothing serves the path, an auth middleware) never + // reached Sentry, so it is sent once more. A 2xx (a static host serving `index.html`) may have been delivered, + // so only the envelopes after it go directly. + const rejected = statusCode >= 400 && statusCode < 500; + const { dsn, sdk } = envelope[0]; + const components = (rejected || (statusCode >= 200 && statusCode < 300)) && dsn && makeDsn(dsn); + if (!components) { + return response; } - // Something other than the tunnel handler answered, like a static host serving `index.html`. This envelope - // may have been delivered all the same, so it isn't sent again, only the ones after it go directly. - if (statusCode !== undefined && statusCode >= 200 && statusCode < 300) { - switchToDirect(envelope, `the response from "${options.url}" didn't come from a Sentry tunnel handler.`); - } + DEBUG_BUILD && + debug.warn(`Sending to Sentry directly: "${options.url}" answered ${statusCode} instead of the tunnel.`); + // Envelopes sent at the same time all land here, and they share one direct transport. + directTransport ??= makeTransport({ + ...options, + url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk), + headers: directHeaders, + }); - return response; + return rejected ? directTransport.send(envelope) : response; }, async flush(timeout?: number): Promise { const results = await Promise.all([tunnelTransport.flush(timeout), directTransport?.flush(timeout) ?? true]); From e6ff11a037864c655cc580a120357d060452b38a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 13:30:39 -0400 Subject: [PATCH 12/19] test(browser): Drop the server-side tunnel match from the browser tunnel tests `isTunnelRequest` only reads the active server client now, and the paths are already checked against `getTunnelPath`. --- packages/browser/test/utils/managedTunnel.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index dc366af408d5..edb2457a2a6e 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -1,6 +1,5 @@ import type { EventEnvelope, EventItem, Transport, TransportMakeRequestResponse } from '@sentry/core'; import { createEnvelope, getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn } from '@sentry/core'; -import { isTunnelRequest } from '@sentry/core/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getClient, getCurrentScope, init } from '../../src'; import { makeFetchTransport } from '../../src/transports/fetch'; @@ -14,7 +13,6 @@ describe('resolveBrowserTunnelOption', () => { const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: true }); expect(tunnel).toBe(getTunnelPath(DSN)); - expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN] })).toBe(true); }); it('marks the tunnel as managed so the client can explain a server that does not answer it', () => { @@ -28,7 +26,6 @@ describe('resolveBrowserTunnelOption', () => { const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs/', trailingSlash: true } }); expect(tunnel).toBe(`/docs${getTunnelPath(DSN)}/`); - expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN], basePath: '/docs' })).toBe(true); }); it('normalizes the base path like the server does, so the tunnel URL is never page-relative', () => { From 695feb99db16368a553223d39827bc08f87d7797 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:27:51 -0400 Subject: [PATCH 13/19] ref(browser): Trim the managed tunnel comments --- packages/aws-serverless/src/init.ts | 2 +- packages/browser/src/transports/fetch.ts | 1 - packages/browser/src/utils/managedTunnel.ts | 18 +++++------------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 48bbceef2f96..e1297befee14 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -106,7 +106,7 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined } } else { DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension'); - // The extension takes over the transport, but `tunnel: true` still has to serve the managed tunnel. + // The extension takes over the transport, but the managed tunnel is still served. opts._managedTunnel = resolveServerTunnelOption(opts.tunnel)._managedTunnel; opts.tunnel = 'http://localhost:9000/envelope'; } diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index cafaac5ea23f..15b324027427 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -50,7 +50,6 @@ export function makeFetchTransport( headers: { 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), 'retry-after': response.headers.get('Retry-After'), - // Tells a managed tunnel's own responses apart from whatever else answers its path. 'x-sentry-tunnel': response.headers.get('X-Sentry-Tunnel'), }, }; diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index c91f03131d72..de1261e15f02 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -11,8 +11,7 @@ import type { BrowserOptions } from '../client'; import type { BrowserTransportOptions } from '../transports/types'; /** - * Resolves the `tunnel` option `init` received. `true` or {@link TunnelOptions} point the tunnel at the same-origin - * path the server SDK derives from the same DSN. + * Resolves `tunnel: true` or {@link TunnelOptions} to the same-origin path the server derives from the DSN. */ export function resolveBrowserTunnelOption( options: BrowserOptions, @@ -35,10 +34,8 @@ export function resolveBrowserTunnelOption( return { tunnel: resolvedTunnel, - // Lets the client explain a tunnel request that nothing on the server answers. _managedTunnel: tunnelOptions, - // The fetch transport sends `text/plain` by default to avoid a CORS preflight. A same-origin request has no - // preflight, and frameworks with CSRF protection (SvelteKit, Astro) reject a `text/plain` POST as a form submission. + // CSRF protection (SvelteKit, Astro) rejects the default `text/plain` POST as a form submission. transportOptions: { ...transportOptions, headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers }, @@ -49,9 +46,8 @@ export function resolveBrowserTunnelOption( let hasWarnedAboutRejectedDsn = false; /** - * Sends to Sentry directly once it's clear nothing on this origin serves the managed tunnel, e.g. in a static build, - * on an SPA host, or when the server SDK isn't set up. An envelope is only sent again when the tunnel's response - * proves it was never ingested, so nothing reaches Sentry twice. + * Falls back to sending directly when nothing on this origin serves the managed tunnel. An envelope is resent only + * when the response proves it never reached Sentry. */ export function makeManagedTunnelTransport( makeTransport: (options: BrowserTransportOptions) => Transport, @@ -70,7 +66,6 @@ export function makeManagedTunnelTransport( const response = await tunnelTransport.send(envelope); const statusCode = response.statusCode || 0; - // The tunnel handler answered, so whatever status it passes on is Sentry's. if (response.headers?.['x-sentry-tunnel']) { if (DEBUG_BUILD && statusCode === 403 && !hasWarnedAboutRejectedDsn) { hasWarnedAboutRejectedDsn = true; @@ -82,9 +77,7 @@ export function makeManagedTunnelTransport( return response; } - // Something other than the tunnel handler answered. A 4xx (nothing serves the path, an auth middleware) never - // reached Sentry, so it is sent once more. A 2xx (a static host serving `index.html`) may have been delivered, - // so only the envelopes after it go directly. + // Not the tunnel: a 4xx never reached Sentry so it's resent, a 2xx may have, so only later envelopes go direct. const rejected = statusCode >= 400 && statusCode < 500; const { dsn, sdk } = envelope[0]; const components = (rejected || (statusCode >= 200 && statusCode < 300)) && dsn && makeDsn(dsn); @@ -94,7 +87,6 @@ export function makeManagedTunnelTransport( DEBUG_BUILD && debug.warn(`Sending to Sentry directly: "${options.url}" answered ${statusCode} instead of the tunnel.`); - // Envelopes sent at the same time all land here, and they share one direct transport. directTransport ??= makeTransport({ ...options, url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk), From a90bd51f04de8845abc16cf79ecf6dab8b3cdd7e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:53:37 -0400 Subject: [PATCH 14/19] fix(aws-serverless): Keep the Lambda extension for a relative tunnel path, which is the managed tunnel to serve --- packages/aws-serverless/src/init.ts | 13 +++++++------ packages/aws-serverless/test/init.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index e1297befee14..19d56203f8cb 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -77,21 +77,24 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined // 2. Environment variable SENTRY_LAYER_EXTENSION (if set) // 3. Default logic based on sdkSource, tunnel, and proxy settings const useLayerExtensionFromEnv = envToBool(process.env.SENTRY_LAYER_EXTENSION, { strict: true }); - const defaultUseLayerExtension = - sdkSource === 'aws-lambda-layer' && typeof options.tunnel !== 'string' && !proxyWouldInterfere; + // A relative path is the managed tunnel's path, not a transport URL. + const { tunnel, _managedTunnel } = resolveServerTunnelOption(options.tunnel); + const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !tunnel && !proxyWouldInterfere; const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension; const opts = { defaultIntegrations: getDefaultIntegrations(options), useLayerExtension, ...options, + tunnel, + _managedTunnel, }; if (opts.useLayerExtension) { if (sdkSource !== 'aws-lambda-layer') { DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.'); - } else if (typeof opts.tunnel === 'string' || proxyWouldInterfere) { - if (typeof opts.tunnel === 'string') { + } else if (opts.tunnel || proxyWouldInterfere) { + if (opts.tunnel) { DEBUG_BUILD && debug.warn( `Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`, @@ -106,8 +109,6 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined } } else { DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension'); - // The extension takes over the transport, but the managed tunnel is still served. - opts._managedTunnel = resolveServerTunnelOption(opts.tunnel)._managedTunnel; opts.tunnel = 'http://localhost:9000/envelope'; } } diff --git a/packages/aws-serverless/test/init.test.ts b/packages/aws-serverless/test/init.test.ts index a7eda1bcfe84..7d210c12ee4c 100644 --- a/packages/aws-serverless/test/init.test.ts +++ b/packages/aws-serverless/test/init.test.ts @@ -73,6 +73,20 @@ describe('init', () => { ); }); + test('keeps the Lambda extension for a relative tunnel path, which is the managed tunnel to serve', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + + init({ tunnel: '/monitor' }); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.objectContaining({ + useLayerExtension: true, + tunnel: 'http://localhost:9000/envelope', + _managedTunnel: { path: '/monitor' }, + }), + ); + }); + test('should not set tunnel when Lambda extension is disabled', () => { mockGetSDKSource.mockReturnValue('aws-lambda-layer'); const options: AwsServerlessOptions = { From 8d266b59600ea9f9906ebc4f2a8f2a85af0ae85f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:00:06 -0400 Subject: [PATCH 15/19] ref: Import the internal tunnel helpers under their `_INTERNAL_` names --- packages/aws-serverless/src/init.ts | 2 +- packages/browser/src/transports/fetch.ts | 4 ++-- packages/browser/src/utils/managedTunnel.ts | 2 +- packages/bun/src/client.ts | 5 ++++- packages/cloudflare/src/baseSdk.ts | 2 +- packages/deno/src/sdk.ts | 5 ++++- packages/node/src/sdk/index.ts | 2 +- packages/vercel-edge/src/sdk.ts | 2 +- 8 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 19d56203f8cb..6d8bfadbf9f9 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,6 +1,6 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core'; -import { resolveServerTunnelOption } from '@sentry/core/server'; +import { _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption } from '@sentry/core/server'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; import { awsIntegration } from '@sentry/server-utils'; diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 15b324027427..80adedb19031 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -1,5 +1,5 @@ import type { Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; -import { createTransport, makePromiseBuffer } from '@sentry/core'; +import { createTransport, makePromiseBuffer, TUNNEL_RESPONSE_HEADER } from '@sentry/core'; import { clearCachedImplementation, getNativeImplementation } from '@sentry/browser-utils'; import type { WINDOW } from '../helpers'; import type { BrowserTransportOptions } from './types'; @@ -50,7 +50,7 @@ export function makeFetchTransport( headers: { 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), 'retry-after': response.headers.get('Retry-After'), - 'x-sentry-tunnel': response.headers.get('X-Sentry-Tunnel'), + 'x-sentry-tunnel': response.headers.get(TUNNEL_RESPONSE_HEADER), }, }; } catch (e) { diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index de1261e15f02..f518480c5663 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -4,7 +4,7 @@ import { getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn, - normalizeTunnelBasePath, + _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import type { BrowserOptions } from '../client'; diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index b4670612e1e0..3b1e9a15e6f4 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -1,5 +1,8 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; -import { resolveServerTunnelOption, ServerRuntimeClient } from '@sentry/core/server'; +import { + _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption, + ServerRuntimeClient, +} from '@sentry/core/server'; import { applySdkMetadata } from '@sentry/core'; import * as os from 'os'; import type { BunClientOptions } from './types'; diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index fdd0f3c89a81..80f3eee99055 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,6 +1,6 @@ import type { Integration } from '@sentry/core'; import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; -import { resolveServerTunnelOption } from '@sentry/core/server'; +import { _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption } from '@sentry/core/server'; import { consoleIntegration, conversationIdIntegration, diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 0ccb10cccf24..18369fb82fc4 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -14,7 +14,10 @@ import { requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; -import { nodeStackLineParser, resolveServerTunnelOption } from '@sentry/core/server'; +import { + nodeStackLineParser, + _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption, +} from '@sentry/core/server'; import { detectOrchestrionSetup, getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; import { DenoClient } from './client'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 3367653fb076..c96048d57d2c 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -17,7 +17,7 @@ import { requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; -import { resolveServerTunnelOption } from '@sentry/core/server'; +import { _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption } from '@sentry/core/server'; import { isMainThread, parentPort } from 'node:worker_threads'; import { detectOrchestrionSetup, getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; import { DEBUG_BUILD } from '../debug-build'; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index b2289ef5c1e5..d25cbbed55bc 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -20,7 +20,7 @@ import { import { _INTERNAL_getReleaseFromCommitEnv as getReleaseFromCommitEnv, nodeStackLineParser, - resolveServerTunnelOption, + _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption, } from '@sentry/core/server'; import { SentryPropagator, From a80ac61c570e1631d9fd4ff32fde3cf7dd7ff2aa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 22 Oct 2026 10:25:02 -0400 Subject: [PATCH 16/19] ref: Narrow `tunnel` to the browser or server tunnel options per SDK --- packages/browser/src/client.ts | 10 +++++++++- packages/browser/src/utils/managedTunnel.ts | 6 +++--- packages/bun/src/types.ts | 9 +++++++-- packages/cloudflare/src/client.ts | 16 +++++++++++++++- packages/core/src/types/options.ts | 10 ++-------- packages/deno/src/types.ts | 16 ++++++++++++++-- packages/node/src/types.ts | 9 +++++++-- packages/vercel-edge/src/types.ts | 9 +++++++-- 8 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index e29efcaeb599..6dc6d2402d86 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -1,4 +1,5 @@ import type { + BrowserTunnelOptions, ClientOptions, Event, EventHint, @@ -6,6 +7,7 @@ import type { ParameterizedString, Scope, SeverityLevel, + TunnelInit, } from '@sentry/core'; import type { BrowserClientReplayOptions } from '@sentry/core/browser'; import type { RouteProvider } from '@sentry/browser-utils'; @@ -91,7 +93,13 @@ type BrowserSpecificOptions = BrowserClientReplayOptions & * Configuration options for the Sentry Browser SDK. * @see @sentry/core Options for more information. */ -export type BrowserOptions = CoreOptions & BrowserSpecificOptions; +export type BrowserOptions = CoreOptions & + BrowserSpecificOptions & { + /** + * See {@link CoreOptions.tunnel}. The browser `init` also takes the browser-only {@link BrowserTunnelOptions}. + */ + tunnel?: TunnelInit; + }; /** * Configuration options for the Sentry Browser SDK Client class diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index f518480c5663..d68806a4bde8 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -1,4 +1,4 @@ -import type { Envelope, Transport, TransportMakeRequestResponse, TunnelOptions } from '@sentry/core'; +import type { BrowserTunnelOptions, Envelope, Transport, TransportMakeRequestResponse } from '@sentry/core'; import { debug, getEnvelopeEndpointWithUrlEncodedAuth, @@ -11,7 +11,7 @@ import type { BrowserOptions } from '../client'; import type { BrowserTransportOptions } from '../transports/types'; /** - * Resolves `tunnel: true` or {@link TunnelOptions} to the same-origin path the server derives from the DSN. + * Resolves `tunnel: true` or {@link BrowserTunnelOptions} to the same-origin path the server derives from the DSN. */ export function resolveBrowserTunnelOption( options: BrowserOptions, @@ -21,7 +21,7 @@ export function resolveBrowserTunnelOption( return { tunnel, transportOptions }; } - const tunnelOptions: TunnelOptions = tunnel === true ? {} : tunnel; + const tunnelOptions: BrowserTunnelOptions = tunnel === true ? {} : tunnel; const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn)); if (!tunnelPath) { return { tunnel: undefined, transportOptions }; diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index ade64f83137f..fe6f975a9978 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -1,4 +1,4 @@ -import type { BaseTransportOptions, ClientOptions, Options } from '@sentry/core'; +import type { BaseTransportOptions, ClientOptions, Options, ServerTunnelOptions, TunnelInit } from '@sentry/core'; import type { ServerRuntimeOptions } from '@sentry/core/server'; /** @@ -35,7 +35,12 @@ export interface BaseBunOptions extends ServerRuntimeOptions { * Configuration options for the Sentry Bun SDK * @see @sentry/core Options for more information. */ -export interface BunOptions extends Options, BaseBunOptions {} +export interface BunOptions extends Options, BaseBunOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry Bun SDK Client class diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 051ffd8627d2..49fd35bc16ea 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,4 +1,13 @@ -import type { CheckIn, ClientOptions, MonitorConfig, Options, Scope, TracePropagationTargets } from '@sentry/core'; +import type { + CheckIn, + ClientOptions, + MonitorConfig, + Options, + Scope, + ServerTunnelOptions, + TunnelInit, + TracePropagationTargets, +} from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, @@ -530,6 +539,11 @@ interface BaseCloudflareOptions { */ export interface CloudflareOptions extends Options, BaseCloudflareOptions { ctx?: ExecutionContextCompat; + + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; } /** diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index e1235e05c3a9..66e10aee1062 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -692,17 +692,11 @@ export interface CoreOptions { /** - * Where to send envelopes instead of the Sentry ingest URL. - * - * - A URL sends envelopes to your own tunnel endpoint. On the server, a relative path such as `/monitor` instead - * serves the managed tunnel at that path, so `tunnel: '/monitor'` on both sides gives a fixed path. - * - `true` (or {@link TunnelOptions}) enables the managed tunnel. Set it in both the browser and the server `init`: - * the browser sends envelopes to a same-origin path derived from its DSN, and the server SDK serves that path and - * forwards the envelopes to Sentry. This keeps ad-blockers from dropping events. + * Where to send envelopes instead of the Sentry ingest URL, see {@link TunnelInit}. * * @default undefined */ - tunnel?: string | true | TunnelOptions; + tunnel?: TunnelInit; /** * If this is set to false, default integrations will not be added, otherwise this will internally be set to the diff --git a/packages/deno/src/types.ts b/packages/deno/src/types.ts index 9e5a8cf53cd6..ffe09fc3e31e 100644 --- a/packages/deno/src/types.ts +++ b/packages/deno/src/types.ts @@ -1,4 +1,11 @@ -import type { BaseTransportOptions, ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; +import type { + BaseTransportOptions, + ClientOptions, + Options, + ServerTunnelOptions, + TunnelInit, + TracePropagationTargets, +} from '@sentry/core'; export interface BaseDenoOptions { /** @@ -56,7 +63,12 @@ export interface BaseDenoOptions { * Configuration options for the Sentry Deno SDK * @see @sentry/core Options for more information. */ -export interface DenoOptions extends Options, BaseDenoOptions {} +export interface DenoOptions extends Options, BaseDenoOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry Deno SDK Client class diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 220c194c6546..97c4dec0e0f2 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, Scope } from '@sentry/core'; +import type { ClientOptions, Options, Scope, ServerTunnelOptions, TunnelInit } from '@sentry/core'; import type { ServerRuntimeOptions } from '@sentry/core/server'; import type { NodeTransportOptions } from './transports'; @@ -73,7 +73,12 @@ export interface BaseNodeOptions extends ServerRuntimeOptions { * Configuration options for the Sentry Node SDK * @see @sentry/core Options for more information. */ -export interface NodeOptions extends Options, BaseNodeOptions {} +export interface NodeOptions extends Options, BaseNodeOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry Node SDK Client class diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts index af568bbfe4bf..ff9427d18d57 100644 --- a/packages/vercel-edge/src/types.ts +++ b/packages/vercel-edge/src/types.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; +import type { ClientOptions, Options, ServerTunnelOptions, TracePropagationTargets, TunnelInit } from '@sentry/core'; import type { VercelEdgeClient } from './client'; import type { VercelEdgeTransportOptions } from './transports'; @@ -58,7 +58,12 @@ export interface BaseVercelEdgeOptions { * Configuration options for the Sentry VercelEdge SDK * @see @sentry/core Options for more information. */ -export interface VercelEdgeOptions extends Options, BaseVercelEdgeOptions {} +export interface VercelEdgeOptions extends Options, BaseVercelEdgeOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry VercelEdge SDK Client class From ba73fb1cd69b0526b2a3b0dae765dda099ac3931 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 23 Oct 2026 10:17:08 -0400 Subject: [PATCH 17/19] feat(browser): Treat a relative tunnel path as a managed one and only fall back on 404 or 405 --- packages/browser/src/utils/managedTunnel.ts | 17 +++--- .../browser/test/utils/managedTunnel.test.ts | 56 +++++++++---------- packages/core/src/types/options.ts | 5 +- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index d68806a4bde8..1097a7071fd2 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -11,17 +11,18 @@ import type { BrowserOptions } from '../client'; import type { BrowserTransportOptions } from '../transports/types'; /** - * Resolves `tunnel: true` or {@link BrowserTunnelOptions} to the same-origin path the server derives from the DSN. + * Resolves `tunnel: true`, a relative path or {@link BrowserTunnelOptions} to the managed tunnel's same-origin path. */ export function resolveBrowserTunnelOption( options: BrowserOptions, ): Pick & { tunnel: string | undefined } { const { tunnel, dsn, transportOptions } = options; - if (typeof tunnel === 'string' || !tunnel) { + if (!tunnel || (typeof tunnel === 'string' && !tunnel.startsWith('/'))) { return { tunnel, transportOptions }; } - const tunnelOptions: BrowserTunnelOptions = tunnel === true ? {} : tunnel; + const tunnelOptions: BrowserTunnelOptions = + tunnel === true ? {} : typeof tunnel === 'string' ? { path: tunnel } : tunnel; const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn)); if (!tunnelPath) { return { tunnel: undefined, transportOptions }; @@ -46,8 +47,7 @@ export function resolveBrowserTunnelOption( let hasWarnedAboutRejectedDsn = false; /** - * Falls back to sending directly when nothing on this origin serves the managed tunnel. An envelope is resent only - * when the response proves it never reached Sentry. + * Falls back to sending directly when nothing on this origin serves the managed tunnel, i.e. it answers 404 or 405. */ export function makeManagedTunnelTransport( makeTransport: (options: BrowserTransportOptions) => Transport, @@ -77,10 +77,9 @@ export function makeManagedTunnelTransport( return response; } - // Not the tunnel: a 4xx never reached Sentry so it's resent, a 2xx may have, so only later envelopes go direct. - const rejected = statusCode >= 400 && statusCode < 500; + // Only a 404 or 405 says nothing serves the path. Any other answer may come from a tunnel the app runs itself. const { dsn, sdk } = envelope[0]; - const components = (rejected || (statusCode >= 200 && statusCode < 300)) && dsn && makeDsn(dsn); + const components = (statusCode === 404 || statusCode === 405) && dsn && makeDsn(dsn); if (!components) { return response; } @@ -93,7 +92,7 @@ export function makeManagedTunnelTransport( headers: directHeaders, }); - return rejected ? directTransport.send(envelope) : response; + return directTransport.send(envelope); }, async flush(timeout?: number): Promise { const results = await Promise.all([tunnelTransport.flush(timeout), directTransport?.flush(timeout) ?? true]); diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index edb2457a2a6e..3c0c70246f79 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -19,7 +19,9 @@ describe('resolveBrowserTunnelOption', () => { expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs' } })._managedTunnel).toEqual({ basePath: '/docs', }); - expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel' })._managedTunnel).toBeUndefined(); + expect( + resolveBrowserTunnelOption({ dsn: DSN, tunnel: 'https://proxy.example.com/tunnel' })._managedTunnel, + ).toBeUndefined(); }); it('prefixes the base path and appends a trailing slash when asked', () => { @@ -45,11 +47,19 @@ describe('resolveBrowserTunnelOption', () => { expect(transportOptions?.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); }); + it('treats a relative path as a shortcut for a fixed path', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/monitor' })).toEqual( + resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }), + ); + }); + it('keeps a URL tunnel and its transport options untouched', () => { const transportOptions = { headers: { 'x-custom': '1' } }; - expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel', transportOptions })).toEqual({ - tunnel: '/my-tunnel', + expect( + resolveBrowserTunnelOption({ dsn: DSN, tunnel: 'https://proxy.example.com/tunnel', transportOptions }), + ).toEqual({ + tunnel: 'https://proxy.example.com/tunnel', transportOptions, }); }); @@ -155,8 +165,8 @@ describe('makeManagedTunnelTransport', () => { ]); }); - it.each([400, 401, 403, 404, 405, 413, 429])( - 'sends an envelope directly once, and everything after it, when something other than the tunnel answers %s', + it.each([404, 405])( + 'sends an envelope directly once, and everything after it, when nothing serves the tunnel and it answers %s', async statusCode => { const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); @@ -189,31 +199,21 @@ describe('makeManagedTunnelTransport', () => { }, ); - it('never sends an envelope again after a 2xx the tunnel handler did not produce, only the ones after it', async () => { - const { transport, sent } = setup({ - [TUNNEL_URL]: [{ statusCode: 200, headers: {} } as TransportMakeRequestResponse], - }); - - await transport.send(makeEnvelope('a')); - await transport.send(makeEnvelope('b')); - - expect(sent.map(({ url, id }) => [url, id])).toEqual([ - [TUNNEL_URL, 'a'], - [DIRECT_URL, 'b'], - ]); - }); - - it.each([500, 503])('never sends an envelope again after a %s', async statusCode => { - const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + it.each([200, 400, 401, 403, 413, 429, 500, 503])( + 'keeps using a tunnel the app serves itself when it answers %s', + async statusCode => { + const appResponse = { statusCode, headers: {} } as TransportMakeRequestResponse; + const { transport, sent } = setup({ [TUNNEL_URL]: [appResponse] }); - await transport.send(makeEnvelope('a')); - await transport.send(makeEnvelope('b')); + expect(await transport.send(makeEnvelope('a'))).toBe(appResponse); + await transport.send(makeEnvelope('b')); - expect(sent.map(({ url, id }) => [url, id])).toEqual([ - [TUNNEL_URL, 'a'], - [TUNNEL_URL, 'b'], - ]); - }); + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }, + ); it('only sends each envelope directly once when several were already on their way to the tunnel', async () => { const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode: 404 }, { statusCode: 404 }] }); diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 66e10aee1062..0e1e65264184 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -677,8 +677,9 @@ export interface ServerTunnelOptions extends TunnelOptions { * 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. + * - A relative path, e.g. `/monitor`, is a shortcut for `{ path: '/monitor' }`: the managed tunnel at that fixed + * path. The browser sends envelopes to it, and the server SDK serves it when its `init` sets the same path. An app + * can also serve the path itself, the browser only sends to Sentry directly when the path answers 404 or 405. * - `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. From ee14703197617e1cf572680b3de0b739d2b20c51 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 23 Oct 2026 10:50:36 -0400 Subject: [PATCH 18/19] ref(browser): Fold the managed tunnel fallback into the fetch transport --- packages/browser/src/sdk.ts | 11 +- packages/browser/src/transports/fetch.ts | 25 ++- packages/browser/src/transports/types.ts | 6 + packages/browser/src/utils/managedTunnel.ts | 73 +------ .../browser/test/transports/fetch.test.ts | 3 +- .../browser/test/utils/managedTunnel.test.ts | 196 ++++++++---------- 6 files changed, 119 insertions(+), 195 deletions(-) diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 8c0046d69e1b..1425fe5c6829 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -24,7 +24,7 @@ import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { normalizeStringifyValue } from './normalizeStringifyValue'; import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension'; -import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from './utils/managedTunnel'; +import { resolveBrowserTunnelOption } from './utils/managedTunnel'; /** Get the default integrations for the browser SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -116,18 +116,13 @@ export function init(options: BrowserOptions = {}): Client | undefined { defaultIntegrations, }); - const tunnelOptions = resolveBrowserTunnelOption(options); - const transport = options.transport || makeFetchTransport; - const clientOptions: BrowserClientOptions = { ...options, - ...tunnelOptions, + ...resolveBrowserTunnelOption(options), enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations, - transport: tunnelOptions._managedTunnel - ? makeManagedTunnelTransport(transport, options.transportOptions?.headers) - : transport, + transport: options.transport || makeFetchTransport, }; setNormalizeStringifier(normalizeStringifyValue); diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 80adedb19031..83a3822f7b60 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -1,6 +1,7 @@ import type { Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; -import { createTransport, makePromiseBuffer, TUNNEL_RESPONSE_HEADER } from '@sentry/core'; +import { createTransport, debug, makePromiseBuffer, TUNNEL_RESPONSE_HEADER } from '@sentry/core'; import { clearCachedImplementation, getNativeImplementation } from '@sentry/browser-utils'; +import { DEBUG_BUILD } from '../debug-build'; import type { WINDOW } from '../helpers'; import type { BrowserTransportOptions } from './types'; @@ -13,10 +14,14 @@ export function makeFetchTransport( options: BrowserTransportOptions, nativeFetch: typeof WINDOW.fetch = getNativeImplementation('fetch'), ): Transport { + const fallbackUrl = options._tunnelFallbackUrl; + let url = options.url; let pendingBodySize = 0; let pendingCount = 0; async function makeRequest(request: TransportRequest): Promise { + const requestUrl = url; + const isTunnel = !!fallbackUrl && requestUrl !== fallbackUrl; const requestSize = request.body.length; pendingBodySize += requestSize; pendingCount++; @@ -25,7 +30,8 @@ export function makeFetchTransport( body: request.body as BodyInit, method: 'POST', referrerPolicy: 'strict-origin', - headers: options.headers, + // CSRF protection (SvelteKit, Astro) rejects the default `text/plain` POST to the tunnel as a form submission. + headers: isTunnel ? { 'Content-Type': 'application/x-sentry-envelope', ...options.headers } : options.headers, // Outgoing requests are usually cancelled when navigating to a different page, causing a "TypeError: Failed to // fetch" error and sending a "network_error" client-outcome - in Chrome, the request status shows "(cancelled)". // The `keepalive` flag keeps outgoing requests alive, even when switching pages. We want this since we're @@ -43,14 +49,23 @@ export function makeFetchTransport( try { // Note: We do not need to suppress tracing here, because we are using the native fetch, instead of our wrapped one. - const response = await nativeFetch(options.url, requestOptions); + const response = await nativeFetch(requestUrl, requestOptions); + const status = response.status; + + // Only a 404 or 405 says nothing serves the tunnel. Any other answer may come from a tunnel the app runs itself. + if (isTunnel && (status === 404 || status === 405) && !response.headers.get(TUNNEL_RESPONSE_HEADER)) { + DEBUG_BUILD && + debug.warn(`Sending to Sentry directly: "${requestUrl}" answered ${status} instead of the tunnel.`); + url = fallbackUrl; + + return await makeRequest(request); + } return { - statusCode: response.status, + statusCode: status, headers: { 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), 'retry-after': response.headers.get('Retry-After'), - 'x-sentry-tunnel': response.headers.get(TUNNEL_RESPONSE_HEADER), }, }; } catch (e) { diff --git a/packages/browser/src/transports/types.ts b/packages/browser/src/transports/types.ts index a304e9f93d66..6e6c384929f2 100644 --- a/packages/browser/src/transports/types.ts +++ b/packages/browser/src/transports/types.ts @@ -3,4 +3,10 @@ import type { BaseTransportOptions } from '@sentry/core'; export interface BrowserTransportOptions extends BaseTransportOptions { /** Fetch API init parameters. Used by the FetchTransport */ fetchOptions?: RequestInit; + /** + * The direct ingest URL `init` sets for a managed tunnel. Envelopes go there when nothing serves the tunnel. + * + * @hidden + */ + _tunnelFallbackUrl?: string; } diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts index 1097a7071fd2..c562bf2afb6e 100644 --- a/packages/browser/src/utils/managedTunnel.ts +++ b/packages/browser/src/utils/managedTunnel.ts @@ -1,4 +1,4 @@ -import type { BrowserTunnelOptions, Envelope, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import type { BrowserTunnelOptions } from '@sentry/core'; import { debug, getEnvelopeEndpointWithUrlEncodedAuth, @@ -8,14 +8,13 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import type { BrowserOptions } from '../client'; -import type { BrowserTransportOptions } from '../transports/types'; /** * Resolves `tunnel: true`, a relative path or {@link BrowserTunnelOptions} to the managed tunnel's same-origin path. */ export function resolveBrowserTunnelOption( options: BrowserOptions, -): Pick & { tunnel: string | undefined } { +): Pick & { tunnel: string | undefined } { const { tunnel, dsn, transportOptions } = options; if (!tunnel || (typeof tunnel === 'string' && !tunnel.startsWith('/'))) { return { tunnel, transportOptions }; @@ -23,7 +22,8 @@ export function resolveBrowserTunnelOption( const tunnelOptions: BrowserTunnelOptions = tunnel === true ? {} : typeof tunnel === 'string' ? { path: tunnel } : tunnel; - const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn)); + const dsnComponents = dsn && makeDsn(dsn); + const tunnelPath = tunnelOptions.path ?? (dsnComponents && getTunnelPath(dsnComponents)); if (!tunnelPath) { return { tunnel: undefined, transportOptions }; } @@ -35,70 +35,11 @@ export function resolveBrowserTunnelOption( return { tunnel: resolvedTunnel, - _managedTunnel: tunnelOptions, - // CSRF protection (SvelteKit, Astro) rejects the default `text/plain` POST as a form submission. transportOptions: { ...transportOptions, - headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers }, + _tunnelFallbackUrl: dsnComponents + ? getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents, undefined, options._metadata?.sdk) + : undefined, }, }; } - -let hasWarnedAboutRejectedDsn = false; - -/** - * Falls back to sending directly when nothing on this origin serves the managed tunnel, i.e. it answers 404 or 405. - */ -export function makeManagedTunnelTransport( - makeTransport: (options: BrowserTransportOptions) => Transport, - directHeaders: BrowserTransportOptions['headers'], -): (options: BrowserTransportOptions) => Transport { - return options => { - const tunnelTransport = makeTransport(options); - let directTransport: Transport | undefined; - - return { - async send(envelope: Envelope): Promise { - if (directTransport) { - return directTransport.send(envelope); - } - - const response = await tunnelTransport.send(envelope); - const statusCode = response.statusCode || 0; - - if (response.headers?.['x-sentry-tunnel']) { - if (DEBUG_BUILD && statusCode === 403 && !hasWarnedAboutRejectedDsn) { - hasWarnedAboutRejectedDsn = true; - debug.warn( - `The managed tunnel at "${options.url}" rejected this DSN, so events sent through it are lost. Add it to the server's \`allowedDsns\`.`, - ); - } - - return response; - } - - // Only a 404 or 405 says nothing serves the path. Any other answer may come from a tunnel the app runs itself. - const { dsn, sdk } = envelope[0]; - const components = (statusCode === 404 || statusCode === 405) && dsn && makeDsn(dsn); - if (!components) { - return response; - } - - DEBUG_BUILD && - debug.warn(`Sending to Sentry directly: "${options.url}" answered ${statusCode} instead of the tunnel.`); - directTransport ??= makeTransport({ - ...options, - url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk), - headers: directHeaders, - }); - - return directTransport.send(envelope); - }, - async flush(timeout?: number): Promise { - const results = await Promise.all([tunnelTransport.flush(timeout), directTransport?.flush(timeout) ?? true]); - - return results.every(Boolean); - }, - }; - }; -} diff --git a/packages/browser/test/transports/fetch.test.ts b/packages/browser/test/transports/fetch.test.ts index 52098701347d..1c1dee2c7f90 100644 --- a/packages/browser/test/transports/fetch.test.ts +++ b/packages/browser/test/transports/fetch.test.ts @@ -73,10 +73,9 @@ describe('fetchTransport', () => { expect(headers.get).toHaveBeenCalledTimes(0); await transport.send(ERROR_ENVELOPE); - expect(headers.get).toHaveBeenCalledTimes(3); + expect(headers.get).toHaveBeenCalledTimes(2); expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); expect(headers.get).toHaveBeenCalledWith('Retry-After'); - expect(headers.get).toHaveBeenCalledWith('X-Sentry-Tunnel'); }); it('allows for custom options to be passed in', async () => { diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index 3c0c70246f79..7c9d67be23ea 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -1,12 +1,12 @@ -import type { EventEnvelope, EventItem, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import type { EventEnvelope, EventItem } from '@sentry/core'; import { createEnvelope, getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getClient, getCurrentScope, init } from '../../src'; import { makeFetchTransport } from '../../src/transports/fetch'; -import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; -import type { BrowserTransportOptions } from '../../src/transports/types'; +import { resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; const DSN = 'https://public@o123.ingest.sentry.io/1337'; +const DIRECT_URL = getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(DSN)!); describe('resolveBrowserTunnelOption', () => { it('resolves `tunnel: true` to the path the server derives from the same DSN', () => { @@ -15,13 +15,10 @@ describe('resolveBrowserTunnelOption', () => { expect(tunnel).toBe(getTunnelPath(DSN)); }); - it('marks the tunnel as managed so the client can explain a server that does not answer it', () => { - expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs' } })._managedTunnel).toEqual({ - basePath: '/docs', + it('gives a managed tunnel the direct ingest URL to fall back to', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs' } }).transportOptions).toEqual({ + _tunnelFallbackUrl: DIRECT_URL, }); - expect( - resolveBrowserTunnelOption({ dsn: DSN, tunnel: 'https://proxy.example.com/tunnel' })._managedTunnel, - ).toBeUndefined(); }); it('prefixes the base path and appends a trailing slash when asked', () => { @@ -41,10 +38,7 @@ describe('resolveBrowserTunnelOption', () => { }); it('uses a fixed path instead of the DSN-derived one', () => { - const { tunnel, transportOptions } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }); - - expect(tunnel).toBe('/monitor'); - expect(transportOptions?.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }).tunnel).toBe('/monitor'); }); it('treats a relative path as a shortcut for a fixed path', () => { @@ -68,42 +62,6 @@ describe('resolveBrowserTunnelOption', () => { expect(resolveBrowserTunnelOption({ tunnel: true }).tunnel).toBeUndefined(); expect(resolveBrowserTunnelOption({ dsn: 'not-a-dsn', tunnel: true }).tunnel).toBeUndefined(); }); - - it('sends envelopes as application/x-sentry-envelope so CSRF checks do not treat them as form posts', async () => { - const { tunnel, transportOptions } = resolveBrowserTunnelOption({ - dsn: DSN, - tunnel: true, - transportOptions: { headers: { 'x-custom': '1' } }, - }); - const nativeFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - - const transport = makeFetchTransport( - { ...transportOptions, url: tunnel!, recordDroppedEvent: () => undefined }, - nativeFetch, - ); - await transport.send( - createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ - [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, - ]), - ); - - expect(nativeFetch).toHaveBeenCalledWith( - getTunnelPath(DSN), - expect.objectContaining({ - headers: { 'Content-Type': 'application/x-sentry-envelope', 'x-custom': '1' }, - }), - ); - }); - - it('lets a user-provided Content-Type header win', () => { - const { transportOptions } = resolveBrowserTunnelOption({ - dsn: DSN, - tunnel: true, - transportOptions: { headers: { 'Content-Type': 'text/plain' } }, - }); - - expect(transportOptions?.headers).toEqual({ 'Content-Type': 'text/plain' }); - }); }); describe('init with `tunnel: true`', () => { @@ -116,11 +74,23 @@ describe('init with `tunnel: true`', () => { expect(getClient()?.getOptions().tunnel).toBe(getTunnelPath(DSN)); }); + + it('only gives the transport a fallback URL for a managed tunnel', () => { + const transport = vi.fn(makeFetchTransport); + + init({ dsn: DSN, tunnel: true, transport }); + const managedTransportOptions = transport.mock.calls[0]?.[0]; + init({ dsn: DSN, tunnel: 'https://proxy.example.com', transport }); + + expect(managedTransportOptions).toEqual( + expect.objectContaining({ url: getTunnelPath(DSN), _tunnelFallbackUrl: DIRECT_URL }), + ); + expect(transport).toHaveBeenLastCalledWith(expect.not.objectContaining({ _tunnelFallbackUrl: expect.anything() })); + }); }); -describe('makeManagedTunnelTransport', () => { +describe('makeFetchTransport with a managed tunnel', () => { const TUNNEL_URL = `https://example.com${getTunnelPath(DSN)}`; - const DIRECT_URL = getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(DSN)!); function makeEnvelope(id: string): EventEnvelope { return createEnvelope({ dsn: DSN, event_id: id }, [ @@ -128,109 +98,107 @@ describe('makeManagedTunnelTransport', () => { ]); } - // Each transport answers with the next response queued for its URL and records what it sent. - function setup(responses: Record): { - transport: Transport; - sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }>; - } { - const sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }> = []; - const makeTransport = (options: BrowserTransportOptions): Transport => ({ - send: envelope => { - sent.push({ url: options.url, id: String(envelope[0].event_id), headers: options.headers }); - - return Promise.resolve(responses[options.url]?.shift() ?? { statusCode: 200 }); - }, - flush: () => Promise.resolve(true), - }); - const transport = makeManagedTunnelTransport(makeTransport, { 'X-Custom': '1' })({ - url: TUNNEL_URL, - headers: { 'Content-Type': 'application/x-sentry-envelope', 'X-Custom': '1' }, - recordDroppedEvent: () => undefined, + // Each URL answers with the next response queued for it, 200 once the queue is empty. + function setup(responses: Record>) { + const nativeFetch = vi.fn((url: string) => { + const { status, tunnel } = responses[url]?.shift() ?? { status: 200 }; + + return Promise.resolve(new Response(null, { status, headers: tunnel ? { 'X-Sentry-Tunnel': '1' } : {} })); }); + const transport = makeFetchTransport( + { + url: TUNNEL_URL, + headers: { 'X-Custom': '1' }, + _tunnelFallbackUrl: DIRECT_URL, + recordDroppedEvent: () => undefined, + }, + nativeFetch as unknown as typeof fetch, + ); + const sent = (): string[][] => + nativeFetch.mock.calls.map(([url], i) => [ + url, + JSON.parse(String(nativeFetch.mock.calls[i]?.[1]?.body).split('\n')[0]!).event_id, + ]); - return { transport, sent }; + return { transport, nativeFetch, sent }; } - const tunnelResponse = { statusCode: 200, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; - - it('keeps sending through the tunnel while its handler answers', async () => { - const { transport, sent } = setup({ [TUNNEL_URL]: [tunnelResponse, tunnelResponse] }); + it('sends envelopes to the tunnel as application/x-sentry-envelope so CSRF checks do not treat them as form posts', async () => { + const { transport, nativeFetch } = setup({}); await transport.send(makeEnvelope('a')); - await transport.send(makeEnvelope('b')); - expect(sent.map(({ url, id }) => [url, id])).toEqual([ - [TUNNEL_URL, 'a'], - [TUNNEL_URL, 'b'], - ]); + expect(nativeFetch).toHaveBeenCalledWith( + TUNNEL_URL, + expect.objectContaining({ headers: { 'Content-Type': 'application/x-sentry-envelope', 'X-Custom': '1' } }), + ); }); it.each([404, 405])( 'sends an envelope directly once, and everything after it, when nothing serves the tunnel and it answers %s', - async statusCode => { - const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + async status => { + const { transport, nativeFetch, sent } = setup({ [TUNNEL_URL]: [{ status }] }); - expect(await transport.send(makeEnvelope('a'))).toEqual({ statusCode: 200 }); + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: 200 })); await transport.send(makeEnvelope('b')); - expect(sent.map(({ url, id }) => [url, id])).toEqual([ + expect(sent()).toEqual([ [TUNNEL_URL, 'a'], [DIRECT_URL, 'a'], [DIRECT_URL, 'b'], ]); // Sentry takes the default `text/plain` without a CORS preflight, so the tunnel's content type is dropped. - expect(sent[1]?.headers).toEqual({ 'X-Custom': '1' }); + expect(nativeFetch).toHaveBeenLastCalledWith( + DIRECT_URL, + expect.objectContaining({ headers: { 'X-Custom': '1' } }), + ); }, ); - it.each([403, 404, 405, 429])( - 'keeps using the tunnel when Sentry answers %s through its handler', - async statusCode => { - const sentryResponse = { statusCode, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; - const { transport, sent } = setup({ [TUNNEL_URL]: [sentryResponse, tunnelResponse] }); + it.each([403, 404, 405])('keeps using the tunnel when its handler answers %s', async status => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status, tunnel: true }] }); - expect(await transport.send(makeEnvelope('a'))).toBe(sentryResponse); - await transport.send(makeEnvelope('b')); + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: status })); + await transport.send(makeEnvelope('b')); - expect(sent.map(({ url, id }) => [url, id])).toEqual([ - [TUNNEL_URL, 'a'], - [TUNNEL_URL, 'b'], - ]); - }, - ); + expect(sent()).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); - it.each([200, 400, 401, 403, 413, 429, 500, 503])( + it.each([200, 400, 401, 403, 413, 500, 503])( 'keeps using a tunnel the app serves itself when it answers %s', - async statusCode => { - const appResponse = { statusCode, headers: {} } as TransportMakeRequestResponse; - const { transport, sent } = setup({ [TUNNEL_URL]: [appResponse] }); + async status => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status }] }); - expect(await transport.send(makeEnvelope('a'))).toBe(appResponse); + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: status })); await transport.send(makeEnvelope('b')); - expect(sent.map(({ url, id }) => [url, id])).toEqual([ + expect(sent()).toEqual([ [TUNNEL_URL, 'a'], [TUNNEL_URL, 'b'], ]); }, ); - it('only sends each envelope directly once when several were already on their way to the tunnel', async () => { - const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode: 404 }, { statusCode: 404 }] }); + it.each([true, false])('never resends a 429 directly (from the tunnel handler: %s)', async tunnel => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status: 429, tunnel }] }); - await Promise.all([transport.send(makeEnvelope('a')), transport.send(makeEnvelope('b'))]); + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: 429 })); - expect(sent.filter(({ url }) => url === DIRECT_URL).map(({ id }) => id)).toEqual(['a', 'b']); + expect(sent()).toEqual([[TUNNEL_URL, 'a']]); }); - it('is used by `init` for a managed tunnel only', () => { - const transport = vi.fn(makeFetchTransport); + it('sends each envelope directly once when several were already on their way to the tunnel', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status: 404 }, { status: 404 }] }); - init({ dsn: DSN, tunnel: true, transport }); - const managedTransportOptions = transport.mock.calls[0]?.[0]; - init({ dsn: DSN, tunnel: 'https://proxy.example.com', transport }); + await Promise.all([transport.send(makeEnvelope('a')), transport.send(makeEnvelope('b'))]); - expect(managedTransportOptions?.url).toBe(getTunnelPath(DSN)); - expect(transport).toHaveBeenLastCalledWith(expect.objectContaining({ url: 'https://proxy.example.com' })); + expect( + sent() + .filter(([url]) => url === DIRECT_URL) + .map(([, id]) => id), + ).toEqual(['a', 'b']); }); }); From 9f5229d2ecc55a72f3ac28d9392c6b2459104e44 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 23 Oct 2026 11:16:07 -0400 Subject: [PATCH 19/19] fix(browser): Resend to Sentry after the tunnel request leaves the keepalive totals --- packages/browser/src/transports/fetch.ts | 27 ++++++++++--------- .../browser/test/utils/managedTunnel.test.ts | 11 ++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 83a3822f7b60..e8f3f0e51e76 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -53,21 +53,19 @@ export function makeFetchTransport( const status = response.status; // Only a 404 or 405 says nothing serves the tunnel. Any other answer may come from a tunnel the app runs itself. - if (isTunnel && (status === 404 || status === 405) && !response.headers.get(TUNNEL_RESPONSE_HEADER)) { - DEBUG_BUILD && - debug.warn(`Sending to Sentry directly: "${requestUrl}" answered ${status} instead of the tunnel.`); - url = fallbackUrl; - - return await makeRequest(request); + if (!isTunnel || (status !== 404 && status !== 405) || response.headers.get(TUNNEL_RESPONSE_HEADER)) { + return { + statusCode: status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + }; } - return { - statusCode: status, - headers: { - 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), - 'retry-after': response.headers.get('Retry-After'), - }, - }; + DEBUG_BUILD && + debug.warn(`Sending to Sentry directly: "${requestUrl}" answered ${status} instead of the tunnel.`); + url = fallbackUrl; } catch (e) { clearCachedImplementation('fetch'); throw e; @@ -75,6 +73,9 @@ export function makeFetchTransport( pendingBodySize -= requestSize; pendingCount--; } + + // Resent once the tunnel request no longer counts as in flight, so the direct send keeps `keepalive`. + return makeRequest(request); } return createTransport( diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts index 7c9d67be23ea..19b4fbd97be9 100644 --- a/packages/browser/test/utils/managedTunnel.test.ts +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -190,6 +190,17 @@ describe('makeFetchTransport with a managed tunnel', () => { expect(sent()).toEqual([[TUNNEL_URL, 'a']]); }); + it('keeps `keepalive` on the direct resend of an envelope the tunnel did not take', async () => { + const { transport, nativeFetch } = setup({ [TUNNEL_URL]: [{ status: 404 }] }); + const envelope = createEnvelope({ dsn: DSN, event_id: 'a' }, [ + [{ type: 'event' }, { event_id: 'a', message: 'x'.repeat(40_000) }] as EventItem, + ]); + + await transport.send(envelope); + + expect(nativeFetch).toHaveBeenLastCalledWith(DIRECT_URL, expect.objectContaining({ keepalive: true })); + }); + it('sends each envelope directly once when several were already on their way to the tunnel', async () => { const { transport, sent } = setup({ [TUNNEL_URL]: [{ status: 404 }, { status: 404 }] });