From a7cdf53f54cd716a64a4fef4a286602754c704bb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:45:22 -0400 Subject: [PATCH 1/4] 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 d7820ecc343e..c631eb6268e6 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 e5b7be5d96dc..f7ca3d380ca6 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 93869d8face1..150e3d3089f7 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'; @@ -110,6 +111,7 @@ export function init(options: DenoOptions = {}): Client { 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 ba3c4ce71830..17b4ab335647 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 { 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 a92cb4c82e6871d942874e2d2e74324a09b6b4c5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:41:20 -0400 Subject: [PATCH 2/4] 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 b87a294792ac..847e877b07c1 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -244,6 +244,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. * @@ -1323,6 +1326,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); @@ -1334,6 +1339,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 3d13a63a426c..dedd597f66b8 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'; @@ -536,6 +536,43 @@ describe('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 6a89f2b7d829aa368cfe32033df0f17fd72ecf30 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:39:20 -0400 Subject: [PATCH 3/4] 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 847e877b07c1..e57abd09582e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -245,7 +245,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. @@ -1326,7 +1325,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) { @@ -1339,22 +1338,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. @@ -1952,3 +1935,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 f103bee0e713f8c0187bceaa75c846b26261cfd2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 22:22:08 -0400 Subject: [PATCH 4/4] 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 = {