From 24ec49108139bef99d5e1d329bea9194bc57514e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:45:22 -0400 Subject: [PATCH 01/14] 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 9cff5450e374..e756ce4c35f3 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,7 +374,7 @@ export interface ClientOptions 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 8b277fb32453..fdcd9daf624a 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 { 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 bc2d74822d65..607d31eed8b6 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, consoleSandbox, conversationIdIntegration, @@ -14,8 +13,10 @@ import { getIntegrationsToSetup, getVercelEnv, GLOBAL_OBJ, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, requestDataIntegration, + resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; import { nodeStackLineParser } from '@sentry/core/server'; @@ -86,6 +87,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 6bc086d9219cdc06393977ea84eee340b3e59afb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:41:20 -0400 Subject: [PATCH 02/14] 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 5723bf29e528..7ae10ae5ccca 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'; @@ -679,6 +679,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 612ce1cbc326f3b45d80018945fcb46f937126e0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:39:20 -0400 Subject: [PATCH 03/14] 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 91e1aac9dd4826b46a31b67b6e0e0e2393215294 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 22:22:08 -0400 Subject: [PATCH 04/14] 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 04636bf849efacc0727c785ddd2826a4d404da4b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:45:46 -0400 Subject: [PATCH 05/14] 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 f9f9abf709b4c201dae2cf283eab65cbda8168a5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:15:26 -0400 Subject: [PATCH 06/14] 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 5b8ed41006b5de6b42e3623ff1c58c72eb621169 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:45:03 -0400 Subject: [PATCH 07/14] 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 d531ec5bba84416ba85b513cf789c1e0f171eb86 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:53:23 -0400 Subject: [PATCH 08/14] 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 493edb81e19c2269a821fdb99d952ae34bd023c0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:06:53 -0400 Subject: [PATCH 09/14] 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 9946214d0c0870051804ca56e2fa65cd665f0a88 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:56:10 -0400 Subject: [PATCH 10/14] 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 | 3 +- 10 files changed, 21 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 7ae10ae5ccca..5723bf29e528 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'; @@ -679,43 +679,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 fdcd9daf624a..566713397895 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 { 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 607d31eed8b6..e909ea255640 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -16,10 +16,9 @@ import { _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, requestDataIntegration, - resolveServerTunnelOption, stackParserFromStackParserOptions, } from '@sentry/core'; -import { nodeStackLineParser } from '@sentry/core/server'; +import { nodeStackLineParser, resolveServerTunnelOption } from '@sentry/core/server'; import { SentryPropagator, SentryTracerProvider, From 16d775d2f5e62bf485ed8a19f8b8c2ac413475fb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:15:01 -0400 Subject: [PATCH 11/14] 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 76e6866332f9c6c2c696985f7830a11b0ab5d21d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 13:30:39 -0400 Subject: [PATCH 12/14] 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 7497f59069f3955cccecc3d443a835108e0d056d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:27:51 -0400 Subject: [PATCH 13/14] 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 8a85d2dc16c91908e357f4ae80e302b43ca96120 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:53:37 -0400 Subject: [PATCH 14/14] 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 = {