From ed011c1fc26ef71348df6806cb31ebd2d9c5304d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:43:32 -0400 Subject: [PATCH 01/93] feat(core): Add managed tunnel helpers that derive the tunnel path from the DSN Browser and server SDKs can now derive the same opaque tunnel path from the DSN with `getTunnelPath`, so they agree on it without a build step. `isTunnelRequest` and `handleTunnelRequestIfMatched` let a server wrapper serve the tunnel when its client has `_managedTunnel` set, which `resolveServerTunnelOption` derives from the `tunnel` option. `allowedDsns` defaults to the client's DSN and the forward to Sentry runs with tracing suppressed. --- packages/core/src/index.ts | 10 +- packages/core/src/types/options.ts | 23 +++ packages/core/src/utils/tunnel.ts | 119 +++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 184 +++++++++++++++++++- 4 files changed, 320 insertions(+), 16 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aae98061c25d..b2962368c856 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,13 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; -export { handleTunnelRequest } from './utils/tunnel'; +export { + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { convertSpanLinksForEnvelope, @@ -415,7 +421,7 @@ export type { HttpHeadersCollection, ResolvedDataCollection, } from './types/datacollection'; -export type { ClientOptions, CoreOptions as Options } from './types/options'; +export type { ClientOptions, CoreOptions as Options, TunnelOptions } from './types/options'; export type { Package } from './types/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics'; export type { diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 8ea3944f1613..ee3ded025cfa 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -373,6 +373,14 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; +/** Options for the managed tunnel, see `tunnel`. */ +export interface TunnelOptions { + /** + * Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN + * here when the browser uses a different one. + */ + allowedDsns?: string[]; + + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ + basePath?: string; + + /** Browser only. Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ + trailingSlash?: boolean; +} + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5645633ff45e..c2a53aa3cf2f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,13 +1,76 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { getClient } from '../currentScopes'; +import { suppressTracing } from '../tracing/trace'; +import type { DsnLike } from '../types/dsn'; +import type { TunnelOptions } from '../types/options'; import { debug } from './debug-logger'; -import { makeDsn } from './dsn'; +import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** Pre-parsed array of allowed DSN strings */ - allowedDsns: Array; + /** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */ + allowedDsns?: Array; +} + +/** + * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own + * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. + */ +export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): { + tunnel: string | undefined; + _managedTunnel?: TunnelOptions; +} { + if (typeof tunnel === 'string' || !tunnel) { + return { tunnel }; + } + + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; +} + +/** + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. + * + * Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it + * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. + */ +export function getTunnelPath(dsn: DsnLike): string | undefined { + const components = makeDsn(dsn); + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; +} + +/** + * Returns true if the request targets the managed tunnel. + * + * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its + * `init` was called with `tunnel: true`. + */ +export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean { + if (method !== 'POST') { + return false; + } + + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return false; + } + + const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, ''); + return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path); +} + +/** + * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller + * can hand the request to the app. + */ +export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { + if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) { + return undefined; + } + + const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + return handleTunnelRequest({ request, allowedDsns }); } /** @@ -19,7 +82,8 @@ export interface HandleTunnelRequestOptions { * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request, allowedDsns } = options; + const { request } = options; + const allowedDsns = resolveAllowedDsns(options.allowedDsns); if (allowedDsns.length === 0) { return new Response('Tunnel not configured', { status: 500 }); @@ -62,15 +126,48 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); try { - return await fetch(sentryIngestUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }); + // The forward is SDK plumbing, not app work, so it must not show up as a span. + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body, + }), + ); } catch (error) { debug.error('Sentry tunnel: failed to forward envelope', error); return new Response('Failed to forward envelope to Sentry', { status: 500 }); } } + +function resolveAllowedDsns(allowedDsns: Array | undefined): Array { + if (allowedDsns) { + return allowedDsns; + } + + const dsn = getClient()?.getDsn(); + return dsn ? [dsnToString(dsn)] : []; +} + +function stripBasePath(pathname: string, basePath: string | undefined): string { + const base = basePath?.replace(/\/$/, ''); + if (!base) { + return pathname; + } + + return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname; +} + +/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */ +function fnv1a(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + // eslint-disable-next-line no-bitwise + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + // eslint-disable-next-line no-bitwise + return hash >>> 0; +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 2b499ce754ae..e5e981ac1807 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,15 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { getCurrentScope, setCurrentClient } from '../../../src'; +import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest } from '../../../src/utils/tunnel'; +import { + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from '../../../src/utils/tunnel'; +import type { TunnelOptions } from '../../../src/types/options'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record): Request { +function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - return new Request('http://localhost/tunnel', { method: 'POST', body }); + return new Request(url, { method: 'POST', body }); } describe('handleTunnelRequest', () => { @@ -22,6 +32,7 @@ describe('handleTunnelRequest', () => { afterEach(() => { vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); }); it('forwards the envelope to Sentry and returns the upstream response', async () => { @@ -133,4 +144,171 @@ describe('handleTunnelRequest', () => { expect(result.status).toBe(500); expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); + + it('forwards with tracing suppressed so the forward does not create a span', async () => { + let suppressed: boolean | undefined; + fetchMock.mockImplementationOnce(() => { + suppressed = isTracingSuppressed(); + return Promise.resolve(new Response('ok')); + }); + + await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); + + expect(suppressed).toBe(true); + }); + + it('defaults allowedDsns to the DSN of the active client', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValueOnce(new Response('ok')); + + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns 500 when allowedDsns is omitted and there is no client', async () => { + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelPath', () => { + it('returns a short opaque path that does not leak the DSN', () => { + const path = getTunnelPath(TEST_DSN); + + expect(path).toMatch(/^\/[0-9a-z]{1,7}$/); + expect(path).not.toContain('sentry'); + }); + + it('is stable for the same DSN and differs between DSNs', () => { + expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); + }); + + it('ignores DSN formatting differences so browser and server derive the same path', () => { + expect(getTunnelPath('https://public:secret@dsn.ingest.sentry.io/1337')).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(makeDsn(TEST_DSN)!)).toBe(getTunnelPath(TEST_DSN)); + }); + + it('returns undefined for an invalid DSN', () => { + expect(getTunnelPath('not-a-dsn')).toBeUndefined(); + }); }); + +describe('isTunnelRequest', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('matches a POST to the tunnel path of an allowed DSN', () => { + expect(isTunnelRequest('POST', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest('POST', `${tunnelPath}/`, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match other methods, other paths, or path prefixes', () => { + expect(isTunnelRequest('GET', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest('POST', '/somewhere', { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest('POST', `${tunnelPath}x`, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('does not match the tunnel path of a DSN that is not allowed', () => { + const otherDsn = 'https://other@example.com/9999'; + + expect(isTunnelRequest('POST', getTunnelPath(otherDsn)!, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('uses the managed tunnel config of the active client when no options are passed', () => { + setTestClient(TEST_DSN); + expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + + setTestClient(TEST_DSN, {}); + expect(isTunnelRequest('POST', tunnelPath)).toBe(true); + }); + + it('matches nothing without a client', () => { + expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + expect(isTunnelRequest('POST', tunnelPath, {})).toBe(false); + }); + + it('matches under a base path', () => { + expect(isTunnelRequest('POST', `/docs${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs/' })).toBe(true); + expect(isTunnelRequest('POST', `/docsx${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe(false); + }); +}); + +describe('handleTunnelRequestIfMatched', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('forwards a matching request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); + + const result = await handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] }); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('forwards using the allowedDsns of the active client managed tunnel config', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: browserDsn }, `http://localhost${getTunnelPath(browserDsn)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(browserDsn)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns undefined without reading the body when the request does not match', () => { + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); + + expect(handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] })).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('resolveServerTunnelOption', () => { + it('keeps a URL as the transport tunnel', () => { + expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); + }); + + it('enables the managed tunnel for `true` and options, and leaves the transport untunneled', () => { + expect(resolveServerTunnelOption(true)).toEqual({ tunnel: undefined, _managedTunnel: {} }); + expect(resolveServerTunnelOption({ allowedDsns: [TEST_DSN] })).toEqual({ + tunnel: undefined, + _managedTunnel: { allowedDsns: [TEST_DSN] }, + }); + }); + + it('leaves everything off when unset', () => { + expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); + }); +}); + +function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { + setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} From 1ed77e76d24582e024c1bd428564b8ce70b667c9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:37:01 -0400 Subject: [PATCH 02/93] ref(core): Pass the whole request to the tunnel matcher and expose `getTunnelPaths` Callers now hand `isTunnelRequest` a request-like object, so the matching rule can look at more of the request later without changing every framework. `getTunnelPaths` covers frameworks that can only register routes. --- packages/core/src/index.ts | 3 +- packages/core/src/utils/tunnel.ts | 44 ++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 76 +++++++++++++++++---- 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2962368c856..f753236a441a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,9 +101,10 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; -export type { HandleTunnelRequestOptions } from './utils/tunnel'; +export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { getTunnelPath, + getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index c2a53aa3cf2f..6eef5c176c07 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -6,6 +6,7 @@ import type { TunnelOptions } from '../types/options'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; +import { parseUrl } from './url'; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ @@ -37,17 +38,46 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions */ export function getTunnelPath(dsn: DsnLike): string | undefined { const components = makeDsn(dsn); + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; } +/** + * The parts of an incoming request the tunnel decision may look at. A fetch `Request` satisfies it, and Node SDKs pass + * an `IncomingMessage` directly. Callers always pass the whole request so the decision can change without touching them. + */ +export interface TunnelRequestLike { + method?: string; + /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ + url?: string; + headers?: Headers | Record; +} + +/** + * Returns the tunnel paths the server accepts, relative to its base path. Frameworks that can only register routes + * use these, everything else should call {@link isTunnelRequest} or {@link handleTunnelRequestIfMatched}. + * + * Without `options`, this uses the managed tunnel config of the active client. + */ +export function getTunnelPaths(options?: TunnelOptions): string[] { + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return []; + } + + return resolveAllowedDsns(tunnelOptions.allowedDsns) + .map(dsn => getTunnelPath(dsn)) + .filter((path): path is string => !!path); +} + /** * Returns true if the request targets the managed tunnel. * * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its * `init` was called with `tunnel: true`. */ -export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean { - if (method !== 'POST') { +export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOptions): boolean { + if (request.method !== 'POST' || !request.url) { return false; } @@ -56,8 +86,9 @@ export function isTunnelRequest(method: string, pathname: string, options?: Tunn return false; } - const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, ''); - return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath).replace(/\/$/, ''); + + return getTunnelPaths(tunnelOptions).includes(path); } /** @@ -65,11 +96,12 @@ export function isTunnelRequest(method: string, pathname: string, options?: Tunn * can hand the request to the app. */ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { - if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) { + if (!isTunnelRequest(request, options)) { return undefined; } const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + return handleTunnelRequest({ request, allowedDsns }); } @@ -148,6 +180,7 @@ function resolveAllowedDsns(allowedDsns: Array | undefined): Array>> 0; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index e5e981ac1807..7b26e62ea2bc 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -6,6 +6,7 @@ import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { getTunnelPath, + getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, @@ -19,6 +20,7 @@ const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); + return new Request(url, { method: 'POST', body }); } @@ -149,6 +151,7 @@ describe('handleTunnelRequest', () => { let suppressed: boolean | undefined; fetchMock.mockImplementationOnce(() => { suppressed = isTracingSuppressed(); + return Promise.resolve(new Response('ok')); }); @@ -209,38 +212,87 @@ describe('isTunnelRequest', () => { }); it('matches a POST to the tunnel path of an allowed DSN', () => { - expect(isTunnelRequest('POST', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(true); - expect(isTunnelRequest('POST', `${tunnelPath}/`, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('does not match other methods, other paths, or path prefixes', () => { - expect(isTunnelRequest('GET', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest('POST', '/somewhere', { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest('POST', `${tunnelPath}x`, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('does not match the tunnel path of a DSN that is not allowed', () => { const otherDsn = 'https://other@example.com/9999'; - expect(isTunnelRequest('POST', getTunnelPath(otherDsn)!, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('uses the managed tunnel config of the active client when no options are passed', () => { setTestClient(TEST_DSN); - expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); setTestClient(TEST_DSN, {}); - expect(isTunnelRequest('POST', tunnelPath)).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); }); it('matches nothing without a client', () => { - expect(isTunnelRequest('POST', tunnelPath)).toBe(false); - expect(isTunnelRequest('POST', tunnelPath, {})).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, {})).toBe(false); }); it('matches under a base path', () => { - expect(isTunnelRequest('POST', `/docs${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs/' })).toBe(true); - expect(isTunnelRequest('POST', `/docsx${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe(false); + expect( + isTunnelRequest({ method: 'POST', url: `/docs${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs/' }), + ).toBe(true); + expect( + isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + }); +}); + +describe('isTunnelRequest request shapes', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + it('accepts a fetch Request with an absolute URL and query', () => { + const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); + + expect(isTunnelRequest(request, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('accepts a Node IncomingMessage-like object', () => { + const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; + + expect(isTunnelRequest(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match without a URL', () => { + expect(isTunnelRequest({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); +}); + +describe('getTunnelPaths', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('returns one path per allowed DSN and skips invalid ones', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect(getTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] })).toEqual([ + getTunnelPath(TEST_DSN), + getTunnelPath(otherDsn), + ]); + }); + + it('uses the managed tunnel config of the active client', () => { + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN); + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN, {}); + expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); }); }); From 9f81b8df13ddcc551d0a3146e0e9b94a71c488a5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:44:30 -0400 Subject: [PATCH 03/93] perf(core): Cache the tunnel path per DSN Servers derive the tunnel paths on every POST, so each DSN is now parsed and hashed once. An invalid DSN in `allowedDsns` also logs once instead of on every request. --- packages/core/src/utils/tunnel.ts | 21 +++++++++++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 10 ++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 6eef5c176c07..e2c2fcf419d1 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -15,6 +15,10 @@ export interface HandleTunnelRequestOptions { allowedDsns?: Array; } +// Servers derive the paths on every POST, so parse and hash each DSN once. This also keeps an invalid DSN in +// `allowedDsns` from logging on every request. Keys come from config, never from requests, so the map stays small. +const tunnelPathCache = new Map(); + /** * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. @@ -37,9 +41,17 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. */ export function getTunnelPath(dsn: DsnLike): string | undefined { + if (typeof dsn === 'string' && tunnelPathCache.has(dsn)) { + return tunnelPathCache.get(dsn); + } + const components = makeDsn(dsn); + const path = components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + if (typeof dsn === 'string') { + tunnelPathCache.set(dsn, path); + } - return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + return path; } /** @@ -193,7 +205,12 @@ function stripBasePath(pathname: string, basePath: string | undefined): string { return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname; } -/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */ +/** + * 32-bit FNV-1a, because the browser needs the path synchronously in `init` (`crypto.subtle.digest` is async) and it + * runs in every runtime without dependencies. It needn't be cryptographic since the DSN is public. + * + * Browser and server SDKs compute the path separately, so changing the algorithm breaks mixed SDK versions. + */ function fnv1a(input: string): number { let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 7b26e62ea2bc..98cd495249b6 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -202,6 +202,16 @@ describe('getTunnelPath', () => { it('returns undefined for an invalid DSN', () => { expect(getTunnelPath('not-a-dsn')).toBeUndefined(); }); + + it('parses each DSN string once, so an invalid DSN logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + getTunnelPath('also-not-a-dsn'); + getTunnelPath('also-not-a-dsn'); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); }); describe('isTunnelRequest', () => { From 449f3f599f88585b223324c7031c5a304d3a0cf8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:11:43 -0400 Subject: [PATCH 04/93] feat(core): Serve a fixed managed tunnel path from a relative `tunnel` on the server A relative `tunnel` can't be a transport URL on the server, so it now names the path the server serves, and `TunnelOptions.path` overrides the DSN-derived path. `tunnel: '/monitor'` on both sides then gives a fixed path the same way `tunnel: true` gives the DSN-derived one. --- packages/core/src/types/options.ts | 3 +++ packages/core/src/utils/tunnel.ts | 11 ++++++++++- packages/core/test/lib/utils/tunnel.test.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index ee3ded025cfa..00d60c38cb80 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -661,6 +661,9 @@ export interface TunnelOptions { */ allowedDsns?: string[]; + /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ + path?: string; + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ basePath?: string; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index e2c2fcf419d1..06db91376838 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -27,10 +27,15 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions tunnel: string | undefined; _managedTunnel?: TunnelOptions; } { - if (typeof tunnel === 'string' || !tunnel) { + if (!tunnel) { return { tunnel }; } + if (typeof tunnel === 'string') { + // A relative path can't be a transport URL on the server, so it names the path to serve instead. + return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; + } + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; } @@ -77,6 +82,10 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { return []; } + if (tunnelOptions.path) { + return [tunnelOptions.path]; + } + return resolveAllowedDsns(tunnelOptions.allowedDsns) .map(dsn => getTunnelPath(dsn)) .filter((path): path is string => !!path); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 98cd495249b6..dc190ab51ab7 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -295,6 +295,13 @@ describe('getTunnelPaths', () => { ]); }); + it('returns a fixed path instead of the DSN-derived ones', () => { + expect(getTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] })).toEqual(['/monitor']); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + true, + ); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -366,6 +373,10 @@ describe('resolveServerTunnelOption', () => { }); }); + it('serves a relative path instead of using it as the transport tunnel', () => { + expect(resolveServerTunnelOption('/monitor')).toEqual({ tunnel: undefined, _managedTunnel: { path: '/monitor' } }); + }); + it('leaves everything off when unset', () => { expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); }); From be49ea1722fe36f4b12b9d5508a170d9a804afef Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:47:06 -0400 Subject: [PATCH 05/93] feat(core): Drop the server spans of managed tunnel requests Server SDKs start the incoming request span before a framework hook gets to answer a tunnel request, so clients with the managed tunnel enabled now add the tunnel paths to `ignoreSpans`. --- packages/core/src/client.ts | 6 +++ packages/core/src/types/options.ts | 2 +- packages/core/src/utils/tunnel.ts | 38 +++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 57 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c61b3975d41b..b87a294792ac 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,6 +41,7 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; +import { getTunnelIgnoreSpanFilter } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -271,6 +272,11 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } + const tunnelSpanFilter = options._managedTunnel && getTunnelIgnoreSpanFilter(options._managedTunnel, this._dsn); + if (tunnelSpanFilter) { + this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), tunnelSpanFilter]; + } + const { beforeSendSpan, traceLifecycle } = this._options; // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, // so a mismatch means it is silently never called. diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 00d60c38cb80..e5b7be5d96dc 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -96,7 +96,7 @@ export type IgnoreSpanAttributeValue = string | boolean | number | string[] | bo * At least one of the properties (`name`, `op`, or `attributes`) must be set. * If multiple are set, all must match for the span to be ignored. */ -type IgnoreSpanFilter = +export type IgnoreSpanFilter = | { /** * Spans with a name matching this pattern will be ignored. diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 06db91376838..49ca1586be92 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,8 +1,10 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; -import type { DsnLike } from '../types/dsn'; -import type { TunnelOptions } from '../types/options'; +import { URL_PATH } from '@sentry/conventions/attributes'; +import type { DsnComponents, DsnLike } from '../types/dsn'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; @@ -82,13 +84,27 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { return []; } - if (tunnelOptions.path) { - return [tunnelOptions.path]; + return tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns)); +} + +/** + * An `ignoreSpans` filter for the server spans of tunnel requests. Server SDKs start the span before a framework + * hook gets to answer the request, so clients add this whenever the managed tunnel is enabled. + */ +export function getTunnelIgnoreSpanFilter( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter | undefined { + const allowedDsns = tunnelOptions.allowedDsns ?? (dsn ? [dsnToString(dsn)] : []); + const paths = tunnelPathsFor(tunnelOptions, allowedDsns).map(escapeStringForRegex); + if (!paths.length) { + return undefined; } - return resolveAllowedDsns(tunnelOptions.allowedDsns) - .map(dsn => getTunnelPath(dsn)) - .filter((path): path is string => !!path); + const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + return { attributes: { [URL_PATH]: new RegExp(`^(?:${basePath})?(?:${paths.join('|')})/?$`) } }; } /** @@ -231,3 +247,11 @@ function fnv1a(input: string): number { // eslint-disable-next-line no-bitwise return hash >>> 0; } + +function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { + if (tunnelOptions.path) { + return [tunnelOptions.path]; + } + + return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index dc190ab51ab7..725c0863f114 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -6,6 +6,7 @@ import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { getTunnelPath, + getTunnelIgnoreSpanFilter, getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, @@ -360,6 +361,62 @@ describe('handleTunnelRequestIfMatched', () => { }); }); +describe('getTunnelIgnoreSpanFilter', () => { + function urlPathMatcher(filter: ReturnType): RegExp { + return (filter as { attributes: { 'url.path': RegExp } }).attributes['url.path']; + } + + it("matches the tunnel path of the client's DSN, with or without a trailing slash", () => { + const matcher = urlPathMatcher(getTunnelIgnoreSpanFilter({}, makeDsn(TEST_DSN))); + const tunnelPath = getTunnelPath(TEST_DSN)!; + + expect(matcher.test(tunnelPath)).toBe(true); + expect(matcher.test(`${tunnelPath}/`)).toBe(true); + expect(matcher.test(`${tunnelPath}x`)).toBe(false); + expect(matcher.test('/api/users')).toBe(false); + }); + + it('follows allowedDsns, a fixed path and the base path', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(otherDsn)!, + ), + ).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( + `/app${getTunnelPath(TEST_DSN)}`, + ), + ).toBe(true); + }); + + it('returns nothing when there is no path to match', () => { + expect(getTunnelIgnoreSpanFilter({}, undefined)).toBeUndefined(); + }); +}); + +describe('client with the managed tunnel enabled', () => { + it('ignores the server spans of tunnel requests and keeps user filters', () => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), + ); + const ignoreSpans = client.getOptions().ignoreSpans!; + + expect(ignoreSpans[0]).toBe('user-filter'); + expect( + (ignoreSpans[1] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test(getTunnelPath(TEST_DSN)!), + ).toBe(true); + }); + + it('leaves ignoreSpans alone without the managed tunnel', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN })); + + expect(client.getOptions().ignoreSpans).toBeUndefined(); + }); +}); + describe('resolveServerTunnelOption', () => { it('keeps a URL as the transport tunnel', () => { expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); From 2b8e37676b864d7ddf64540bd51d5bd493609d71 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 06/93] fix(core): Skip fetch instrumentation while tracing is suppressed --- packages/core/src/integrations/fetch.ts | 4 +++- .../core/test/lib/integrations/fetch.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index 5cf7a0489719..dd5fe7e16ced 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -5,6 +5,7 @@ import { getClient } from '../currentScopes'; import { instrumentFetchRequest } from '../fetch'; import { defineIntegration } from '../integration'; import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import { isTracingSuppressed } from '../tracing/trace'; import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; import type { HandlerDataFetch } from '../types/instrument'; import type { Integration, IntegrationFn } from '../types/integration'; @@ -92,7 +93,8 @@ export function createFetchIntegration({ return; } - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + // Matches Node's http and undici instrumentation, so SDK-internal requests (e.g. a tunnel forward) leave no breadcrumb. + if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index 4c332b479fa0..4362c15dd2fd 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -4,6 +4,7 @@ import * as currentScopesModule from '../../../src/currentScopes'; import * as fetchModule from '../../../src/fetch'; import { createFetchIntegration } from '../../../src/integrations/fetch'; import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import { suppressTracing } from '../../../src/tracing/trace'; import type { HandlerDataFetch } from '../../../src/types/instrument'; import type { Integration } from '../../../src/types/integration'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -136,6 +137,23 @@ describe('createFetchIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); + it('does not instrument requests made while tracing is suppressed', () => { + const handler = setupIntegration(fetchIntegration(), client); + + suppressTracing(() => + handler({ + fetchData: { url: 'https://o1.ingest.us.sentry.io/api/2/envelope/', method: 'POST' }, + args: ['https://o1.ingest.us.sentry.io/api/2/envelope/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }), + ); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + it('applies the `shouldCreateSpanForRequest` option', () => { const handler = setupIntegration( fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), From 2b03c6ae2e8b49e92842d728befb26669fd2369c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 07/93] feat(core): Check the tunnel DSN before reading the envelope body --- packages/core/src/utils/tunnel.ts | 94 +++++++++++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 74 +++++++++++++++- 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 49ca1586be92..00756ac11a1d 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -158,11 +158,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Tunnel not configured', { status: 500 }); } - const body = new Uint8Array(await request.arrayBuffer()); + const reader = request.body?.getReader(); + const head = reader && (await readEnvelopeHeaderLine(reader)); + if (!reader || !head) { + return new Response('Invalid envelope', { status: 400 }); + } let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(body); + [envelopeHeader] = parseEnvelope(head.line); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -199,11 +203,11 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return await suppressTracing(() => fetch(sentryIngestUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }), + headers: forwardHeaders(request), + // The body streams through without being buffered, so its size is left for Sentry to judge. + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), ); } catch (error) { debug.error('Sentry tunnel: failed to forward envelope', error); @@ -211,6 +215,82 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): } } +/** + * The header is a small JSON object, so a line longer than this is not an envelope. + */ +const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; + +/** + * Reads the request body only up to the end of the envelope header line. + * Returns the line and every byte read so far, or `undefined` when no header line fits the limit. + */ +async function readEnvelopeHeaderLine( + reader: ReadableStreamDefaultReader, +): Promise<{ line: Uint8Array; read: Uint8Array } | undefined> { + let read: Uint8Array = new Uint8Array(0); + + while (read.length <= MAX_ENVELOPE_HEADER_BYTES) { + const { done, value } = await reader.read(); + if (done) { + return read.length ? { line: read, read } : undefined; + } + + const searchFrom = read.length; + read = concatBytes(read, value); + + const newline = read.indexOf(0x0a, searchFrom); + if (newline !== -1) { + return { line: read.subarray(0, newline), read }; + } + } + + await reader.cancel(); + + return undefined; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length); + merged.set(a); + merged.set(b, a.length); + + return merged; +} + +function prependToStream( + prefix: Uint8Array, + reader: ReadableStreamDefaultReader, +): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(prefix); + }, + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +function forwardHeaders(request: Request): Record { + const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; + // Keeps the upload fixed-length where the runtime allows it (Node, Bun), instead of chunked. + const contentLength = request.headers.get('content-length'); + if (contentLength) { + headers['Content-Length'] = contentLength; + } + + return headers; +} + function resolveAllowedDsns(allowedDsns: Array | undefined): Array { if (allowedDsns) { return allowedDsns; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 725c0863f114..1f055b8f6338 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -25,6 +25,25 @@ function makeEnvelopeRequest(envelopeHeader: Record, url = 'htt return new Request(url, { method: 'POST', body }); } +/** + * A request whose body starts with `head` and then never ends, so buffering it would hang the test. + */ +function makeEndlessRequest(head: string): Request { + const filler = new Uint8Array(16 * 1024).fill(0x61); + let sentHead = false; + + return new Request('http://localhost/tunnel', { + method: 'POST', + duplex: 'half', + body: new ReadableStream({ + pull(controller) { + controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); + sentHead = true; + }, + }), + } as RequestInit); +} + describe('handleTunnelRequest', () => { let fetchMock: ReturnType; @@ -52,11 +71,64 @@ describe('handleTunnelRequest', () => { expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); - expect(init.body).toBeInstanceOf(Uint8Array); + expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); expect(result).toBe(upstreamResponse); }); + it('forwards the content length of the incoming request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const body = `${JSON.stringify({ dsn: TEST_DSN })}\n`; + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': String(body.length) }, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', 'Content-Length': String(body.length) }, + }), + ); + }); + + it('rejects a disallowed DSN after reading only the envelope header', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { + const upstreamResponse = new Response('ok'); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBe(upstreamResponse); + }); + + it('returns 400 when the envelope header line exceeds the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(''), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('returns 500 when allowedDsns is empty', async () => { const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), From 4fcb0cb1b10c7e10a7cd64fb39245a410ce58b6e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:25:49 -0400 Subject: [PATCH 08/93] fix(core): Cancel the body of a rejected tunnel request --- packages/core/src/utils/tunnel.ts | 49 +++++++++++++-------- packages/core/test/lib/utils/tunnel.test.ts | 19 +++++++- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 00756ac11a1d..2ec3bc1f3aea 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -164,9 +164,38 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid envelope', { status: 400 }); } + const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); + if (typeof sentryIngestUrl !== 'string') { + // Nothing past the header gets forwarded, so the runtime can stop receiving the body. + reader.cancel().catch(() => undefined); + + return sentryIngestUrl; + } + + try { + // The forward is SDK plumbing, not app work, so it must not show up as a span. + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: forwardHeaders(request), + // The body streams through without being buffered, so its size is left for Sentry to judge. + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), + ); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } +} + +/** + * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. + */ +function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(head.line); + [envelopeHeader] = parseEnvelope(headerLine); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -196,23 +225,7 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid DSN', { status: 403 }); } - const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); - - try { - // The forward is SDK plumbing, not app work, so it must not show up as a span. - return await suppressTracing(() => - fetch(sentryIngestUrl, { - method: 'POST', - headers: forwardHeaders(request), - // The body streams through without being buffered, so its size is left for Sentry to judge. - body: prependToStream(head.read, reader), - duplex: 'half', - } as RequestInit), - ); - } catch (error) { - debug.error('Sentry tunnel: failed to forward envelope', error); - return new Response('Failed to forward envelope to Sentry', { status: 500 }); - } + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); } /** diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 1f055b8f6338..509ac9e81971 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -28,7 +28,7 @@ function makeEnvelopeRequest(envelopeHeader: Record, url = 'htt /** * A request whose body starts with `head` and then never ends, so buffering it would hang the test. */ -function makeEndlessRequest(head: string): Request { +function makeEndlessRequest(head: string, onCancel?: () => void): Request { const filler = new Uint8Array(16 * 1024).fill(0x61); let sentHead = false; @@ -40,6 +40,7 @@ function makeEndlessRequest(head: string): Request { controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); sentHead = true; }, + cancel: onCancel, }), } as RequestInit); } @@ -107,6 +108,17 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('cancels the body of a rejected request', async () => { + const onCancel = vi.fn(); + + await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), + allowedDsns: [TEST_DSN], + }); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { const upstreamResponse = new Response('ok'); fetchMock.mockResolvedValueOnce(upstreamResponse); @@ -262,6 +274,11 @@ describe('getTunnelPath', () => { expect(path).not.toContain('sentry'); }); + // Browser and server SDKs of different versions must derive the same path, so the hash must never change. + it('derives a fixed path for a known DSN', () => { + expect(getTunnelPath(TEST_DSN)).toBe('/fdv680'); + }); + it('is stable for the same DSN and differs between DSNs', () => { expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); From 2af4e5e30642ac4d83c111312a0da6ca6b615b6a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:28:11 -0400 Subject: [PATCH 09/93] feat(core): Forward the client IP of tunnel requests to Sentry --- packages/core/src/utils/tunnel.ts | 10 +++++++++ packages/core/test/lib/utils/tunnel.test.ts | 24 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 2ec3bc1f3aea..ebf5f3eb685f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -301,6 +301,16 @@ function forwardHeaders(request: Request): Record { headers['Content-Length'] = contentLength; } + // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the + // visitor's IP in these headers, in the order Relay itself reads them. + const clientIp = + request.headers.get('cf-connecting-ip') || + request.headers.get('x-vercel-forwarded-for') || + request.headers.get('x-forwarded-for'); + if (clientIp) { + headers['X-Sentry-Forwarded-For'] = clientIp; + } + return headers; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 509ac9e81971..057a983f8916 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -98,6 +98,30 @@ describe('handleTunnelRequest', () => { ); }); + it.each([ + ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], + ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], + ['x-forwarded-for', { 'x-forwarded-for': '2.2.2.2, 10.0.0.1' }, '2.2.2.2, 10.0.0.1'], + ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + headers: incomingHeaders, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Sentry-Forwarded-For': forwardedFor }), + }), + ); + }); + it('rejects a disallowed DSN after reading only the envelope header', async () => { const result = await handleTunnelRequest({ request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), From fda805ec0a49390a6030c5a91e824126a330faa2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:38:40 -0400 Subject: [PATCH 10/93] fix(core): Match a fixed tunnel path that ends with a slash --- packages/core/src/utils/tunnel.ts | 2 +- packages/core/test/lib/utils/tunnel.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index ebf5f3eb685f..1f8570b371ad 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -353,7 +353,7 @@ function fnv1a(input: string): number { function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { if (tunnelOptions.path) { - return [tunnelOptions.path]; + return [tunnelOptions.path.replace(/\/$/, '')]; } return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 057a983f8916..3d13a63a426c 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -416,6 +416,11 @@ describe('getTunnelPaths', () => { ); }); + it('matches a fixed path that ends with a slash', () => { + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -498,6 +503,7 @@ describe('getTunnelIgnoreSpanFilter', () => { ), ).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); expect( urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( `/app${getTunnelPath(TEST_DSN)}`, From d348c1b7f8aed776c513b9ccee29d763805b30e5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 09:57:47 -0400 Subject: [PATCH 11/93] fix(core): Only answer the managed tunnel under its base path --- packages/core/src/utils/tunnel.ts | 13 ++++++++----- packages/core/test/lib/utils/tunnel.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 1f8570b371ad..b586c83a8d7d 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -104,7 +104,7 @@ export function getTunnelIgnoreSpanFilter( const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return { attributes: { [URL_PATH]: new RegExp(`^(?:${basePath})?(?:${paths.join('|')})/?$`) } }; + return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } /** @@ -123,9 +123,9 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOpti return false; } - const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath).replace(/\/$/, ''); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); - return getTunnelPaths(tunnelOptions).includes(path); + return path !== undefined && getTunnelPaths(tunnelOptions).includes(path.replace(/\/$/, '')); } /** @@ -324,13 +324,16 @@ function resolveAllowedDsns(allowedDsns: Array | undefined): Array { isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), ).toBe(false); }); + + it('matches nothing outside the base path', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe( + false, + ); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe(false); + }); }); describe('isTunnelRequest request shapes', () => { @@ -509,6 +516,9 @@ describe('getTunnelIgnoreSpanFilter', () => { `/app${getTunnelPath(TEST_DSN)}`, ), ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN))).test(getTunnelPath(TEST_DSN)!), + ).toBe(false); }); it('returns nothing when there is no path to match', () => { From 69719b6f8d9a27498ac3b8030176b4aef79f43d5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 10:34:20 -0400 Subject: [PATCH 12/93] feat(core): Always accept the server's own DSN in the managed tunnel --- packages/core/src/types/options.ts | 2 +- packages/core/src/utils/tunnel.ts | 27 +++++++++++-------- packages/core/test/lib/utils/tunnel.test.ts | 29 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index e5b7be5d96dc..d2896dd3979b 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -656,7 +656,7 @@ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; /** Options for the managed tunnel, see `tunnel`. */ export interface TunnelOptions { /** - * Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN + * Server only. DSNs whose envelopes the tunnel forwards, in addition to the server's own DSN. List the browser DSN * here when the browser uses a different one. */ allowedDsns?: string[]; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index b586c83a8d7d..72a6c4898051 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -13,7 +13,7 @@ import { parseUrl } from './url'; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */ + /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ allowedDsns?: Array; } @@ -95,8 +95,9 @@ export function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { - const allowedDsns = tunnelOptions.allowedDsns ?? (dsn ? [dsnToString(dsn)] : []); - const paths = tunnelPathsFor(tunnelOptions, allowedDsns).map(escapeStringForRegex); + const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( + escapeStringForRegex, + ); if (!paths.length) { return undefined; } @@ -314,14 +315,17 @@ function forwardHeaders(request: Request): Record { return headers; } -function resolveAllowedDsns(allowedDsns: Array | undefined): Array { - if (allowedDsns) { - return allowedDsns; - } - - const dsn = getClient()?.getDsn(); +/** + * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock + * out a browser that shares it. It goes last, because React Router can serve only the first path. + */ +function resolveAllowedDsns( + allowedDsns: Array | undefined, + ownDsn: DsnComponents | undefined = getClient()?.getDsn(), +): Array { + const dsns = [...(allowedDsns ?? []), ...(ownDsn ? [dsnToString(ownDsn)] : [])]; - return dsn ? [dsnToString(dsn)] : []; + return [...new Set(dsns)]; } /** @@ -359,5 +363,6 @@ function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array return [tunnelOptions.path.replace(/\/$/, '')]; } - return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); + // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + return [...new Set(allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path))]; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 4b87be886fdc..9a63f02c74ad 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -269,6 +269,23 @@ describe('handleTunnelRequest', () => { expect(suppressed).toBe(true); }); + it('accepts the DSN of the active client on top of allowedDsns', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValue(new Response('ok')); + + const ownDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + const unlistedDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://other@o3.ingest.sentry.io/7' }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + + expect(ownDsnResult.status).toBe(200); + expect(unlistedDsnResult.status).toBe(403); + }); + it('defaults allowedDsns to the DSN of the active client', async () => { setTestClient(TEST_DSN); fetchMock.mockResolvedValueOnce(new Response('ok')); @@ -428,6 +445,13 @@ describe('getTunnelPaths', () => { expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); }); + it('serves the path of the active client DSN on top of allowedDsns, once', () => { + const browserDsn = 'https://browser@o2.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn, TEST_DSN] }); + + expect(getTunnelPaths()).toEqual([getTunnelPath(browserDsn), getTunnelPath(TEST_DSN)]); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -509,6 +533,11 @@ describe('getTunnelIgnoreSpanFilter', () => { getTunnelPath(otherDsn)!, ), ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(TEST_DSN)!, + ), + ).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); expect( From 95d228fc06727a3f78b6a0cb5a69a2ccf81448e8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:47:06 -0400 Subject: [PATCH 13/93] feat(core): Add `applyTunnelDefaults` so framework SDKs fill in the base path the same way --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 20 ++++++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 23 +++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f753236a441a..4a15ed3fdf32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,6 +103,7 @@ export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { + applyTunnelDefaults, getTunnelPath, getTunnelPaths, handleTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 72a6c4898051..82ead49d7f2b 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -41,6 +41,26 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; } +/** + * Fills in the defaults a framework SDK knows about its app, like the base path, for a managed tunnel. Options the + * user set win. A relative `tunnel` path is a managed tunnel with that `path`, so it gets the defaults too, while a + * URL and an unset `tunnel` are returned as they are. + */ +export function applyTunnelDefaults( + tunnel: string | true | TunnelOptions | undefined, + defaults: TunnelOptions | undefined, +): string | true | TunnelOptions | undefined { + if (!tunnel || !defaults || !Object.keys(defaults).length) { + return tunnel; + } + + if (typeof tunnel === 'string') { + return tunnel.startsWith('/') ? { ...defaults, path: tunnel } : tunnel; + } + + return { ...defaults, ...(tunnel === true ? {} : tunnel) }; +} + /** * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. * diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 9a63f02c74ad..a9b0deeb3ed8 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -5,6 +5,7 @@ import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { + applyTunnelDefaults, getTunnelPath, getTunnelIgnoreSpanFilter, getTunnelPaths, @@ -597,6 +598,28 @@ describe('resolveServerTunnelOption', () => { }); }); +describe('applyTunnelDefaults', () => { + it('fills in the defaults for `true` and options, and lets options the user set win', () => { + expect(applyTunnelDefaults(true, { basePath: '/app' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults({ allowedDsns: [TEST_DSN] }, { basePath: '/app' })).toEqual({ + basePath: '/app', + allowedDsns: [TEST_DSN], + }); + expect(applyTunnelDefaults({ basePath: '/custom' }, { basePath: '/app' })).toEqual({ basePath: '/custom' }); + }); + + it('treats a relative path as a managed tunnel with that path', () => { + expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + }); + + it('leaves a URL, an unset tunnel and empty defaults alone', () => { + expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); + expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); + expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); + expect(applyTunnelDefaults(true, undefined)).toBe(true); + }); +}); + function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From f35b3a379243c15a32bf6de5e79fae38a1d3f128 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:59:20 -0400 Subject: [PATCH 14/93] feat(core): Add `setManagedTunnelBasePath` for servers that learn the base path after `init` --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 21 ++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 38 +++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a15ed3fdf32..f8dd7cb802da 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -110,6 +110,7 @@ export { handleTunnelRequestIfMatched, isTunnelRequest, resolveServerTunnelOption, + setManagedTunnelBasePath, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 82ead49d7f2b..66919a149e06 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,4 +1,5 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -128,6 +129,26 @@ export function getTunnelIgnoreSpanFilter( return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } +/** + * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime + * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. + */ +export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel; + const normalizedBasePath = basePath?.replace(/\/+$/, ''); + if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { + return; + } + + options._managedTunnel = { ...tunnelOptions, basePath: normalizedBasePath }; + + const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); + if (spanFilter) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), spanFilter]; + } +} + /** * Returns true if the request targets the managed tunnel. * diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index a9b0deeb3ed8..648d1e077923 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -13,6 +13,7 @@ import { handleTunnelRequestIfMatched, isTunnelRequest, resolveServerTunnelOption, + setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; import type { TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -620,6 +621,43 @@ describe('applyTunnelDefaults', () => { }); }); +describe('setManagedTunnelBasePath', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('serves the tunnel under the base path and drops its spans there', () => { + setTestClient(TEST_DSN, {}); + const client = getCurrentScope().getClient()!; + + setManagedTunnelBasePath(client, '/app/'); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); + expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) })).toBe(false); + expect(client.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN)), + ); + }); + + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { + setTestClient(TEST_DSN, { basePath: '/custom' }); + const client = getCurrentScope().getClient()!; + setManagedTunnelBasePath(client, '/app'); + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + + setTestClient(TEST_DSN, {}); + const rootClient = getCurrentScope().getClient()!; + setManagedTunnelBasePath(rootClient, '/'); + expect(rootClient.getOptions()._managedTunnel).toEqual({}); + + setTestClient(TEST_DSN); + const offClient = getCurrentScope().getClient()!; + setManagedTunnelBasePath(offClient, '/app'); + expect(offClient.getOptions()._managedTunnel).toBeUndefined(); + }); +}); + function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From b9d60f87cd3134fae1693181a4bd630d93ae6ba9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:13:08 -0400 Subject: [PATCH 15/93] feat(core): Mark the tunnel handler's responses so the browser can tell them apart --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 20 ++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 29 ++++++++++++++++++--- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8dd7cb802da..41f56d747dd7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,6 +111,7 @@ export { isTunnelRequest, resolveServerTunnelOption, setManagedTunnelBasePath, + TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 66919a149e06..76032b0851c3 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -193,6 +193,26 @@ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelO * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + return markTunnelResponse(await forwardTunnelRequest(options)); +} + +/** + * Set on every response the tunnel handler returns, so a browser can tell it apart from whatever else answers the + * tunnel path, e.g. a static host serving `index.html` for every path. + */ +export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; + +function markTunnelResponse(response: Response): Response { + const headers = new Headers(response.headers); + // `fetch` already decoded the body, so its original encoding and length no longer describe it. + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.set(TUNNEL_RESPONSE_HEADER, '1'); + + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promise { const { request } = options; const allowedDsns = resolveAllowedDsns(options.allowedDsns); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 648d1e077923..ef2d901fc600 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -76,7 +76,30 @@ describe('handleTunnelRequest', () => { expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); - expect(result).toBe(upstreamResponse); + expect(result.status).toBe(200); + expect(await result.text()).toBe('ok'); + }); + + it("marks every response it returns as the tunnel's, so the browser can tell it from a static host", async () => { + fetchMock.mockResolvedValueOnce( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }), + ); + + const forwarded = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + const rejected = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://public@other.ingest.sentry.io/1' }), + allowedDsns: [TEST_DSN], + }); + + expect(forwarded.headers.get('X-Sentry-Tunnel')).toBe('1'); + expect(forwarded.headers.get('Content-Type')).toBe('application/json'); + // `fetch` already decoded the body, so the original encoding would make the browser decode it twice. + expect(forwarded.headers.get('Content-Encoding')).toBeNull(); + expect(rejected.status).toBe(403); + expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); }); it('forwards the content length of the incoming request', async () => { @@ -154,7 +177,7 @@ describe('handleTunnelRequest', () => { allowedDsns: [TEST_DSN], }); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 400 when the envelope header line exceeds the limit', async () => { @@ -242,7 +265,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchMock.mock.calls[0]!; expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 500 when fetch throws a network error', async () => { From 0ad10d47e2525516813d91dcc0a2bc1768cdaa61 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:00:11 -0400 Subject: [PATCH 16/93] fix(core): Normalize tunnel base paths with one util that doesn't backtrack --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 15 ++++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41f56d747dd7..578bc3e949b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export { handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, + normalizeTunnelBasePath, resolveServerTunnelOption, setManagedTunnelBasePath, TUNNEL_RESPONSE_HEADER, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 76032b0851c3..0091244730e0 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -123,12 +123,21 @@ export function getTunnelIgnoreSpanFilter( return undefined; } - const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } +/** + * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. + */ +export function normalizeTunnelBasePath(basePath: string | undefined): string { + const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); + + return !path || path.startsWith('/') ? path : `/${path}`; +} + /** * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. @@ -136,7 +145,7 @@ export function getTunnelIgnoreSpanFilter( export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { const options = client?.getOptions(); const tunnelOptions = options?._managedTunnel; - const normalizedBasePath = basePath?.replace(/\/+$/, ''); + const normalizedBasePath = normalizeTunnelBasePath(basePath); if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { return; } @@ -393,7 +402,7 @@ function resolveAllowedDsns( * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. */ function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { - const base = basePath?.replace(/\/$/, ''); + const base = normalizeTunnelBasePath(basePath); if (!base) { return pathname; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index ef2d901fc600..87532a659dc3 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -12,6 +12,7 @@ import { handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, + normalizeTunnelBasePath, resolveServerTunnelOption, setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; @@ -644,6 +645,21 @@ describe('applyTunnelDefaults', () => { }); }); +describe('normalizeTunnelBasePath', () => { + it.each([ + ['/app', '/app'], + ['/app/', '/app'], + ['app', '/app'], + ['./app/', '/app'], + ['./', ''], + ['/', ''], + ['', ''], + [undefined, ''], + ])('normalizes %j to %j', (input, expected) => { + expect(normalizeTunnelBasePath(input)).toBe(expected); + }); +}); + describe('setManagedTunnelBasePath', () => { afterEach(() => { getCurrentScope().setClient(undefined); From ee9dcd515a3b5816f79471177561e18982787812 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:07:25 -0400 Subject: [PATCH 17/93] fix(core): Leave a fixed tunnel path to the app when there is no DSN to forward to --- packages/core/src/utils/tunnel.ts | 5 +++++ packages/core/test/lib/utils/tunnel.test.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 0091244730e0..4ef2765216e6 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -189,6 +189,11 @@ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelO } const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes + // the browser send directly, instead of losing every envelope to an error response. + if (!resolveAllowedDsns(allowedDsns).length) { + return undefined; + } return handleTunnelRequest({ request, allowedDsns }); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 87532a659dc3..3cbcb2b7b132 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -527,6 +527,15 @@ describe('handleTunnelRequestIfMatched', () => { ); }); + it('leaves a fixed path to the app when there is no DSN to forward to', () => { + setTestClient(undefined, { path: '/monitor' }); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(handleTunnelRequestIfMatched(request, { path: '/monitor' })).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + }); + it('returns undefined without reading the body when the request does not match', () => { const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); @@ -697,6 +706,6 @@ describe('setManagedTunnelBasePath', () => { }); }); -function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { +function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From 56537983752b2c92c8ade28f70c5f0cb00e78158 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:41:09 -0400 Subject: [PATCH 18/93] fix(core): Don't add the base path again to a tunnel path that already starts with it --- packages/core/src/utils/tunnel.ts | 16 +++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 23 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 4ef2765216e6..48b8f952877f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -56,10 +56,10 @@ export function applyTunnelDefaults( } if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? { ...defaults, path: tunnel } : tunnel; + return tunnel.startsWith('/') ? withPathBelowBasePath({ ...defaults, path: tunnel }) : tunnel; } - return { ...defaults, ...(tunnel === true ? {} : tunnel) }; + return withPathBelowBasePath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); } /** @@ -150,7 +150,7 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s return; } - options._managedTunnel = { ...tunnelOptions, basePath: normalizedBasePath }; + options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); if (spanFilter) { @@ -403,6 +403,16 @@ function resolveAllowedDsns( return [...new Set(dsns)]; } +/** + * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to + * be before the SDK added the base path, would otherwise be served under the base path twice. + */ +function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { + const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); + + return path ? { ...tunnelOptions, path } : tunnelOptions; +} + /** * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. */ diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 3cbcb2b7b132..d7dc09ebdad4 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -646,6 +646,18 @@ describe('applyTunnelDefaults', () => { expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); }); + it('does not add the base path again to a path that already starts with it', () => { + expect(applyTunnelDefaults('/app/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + expect(applyTunnelDefaults({ path: '/app/monitor' }, { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/monitor', + }); + expect(applyTunnelDefaults('/application/monitor', { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/application/monitor', + }); + }); + it('leaves a URL, an unset tunnel and empty defaults alone', () => { expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); @@ -688,6 +700,17 @@ describe('setManagedTunnelBasePath', () => { ); }); + it('does not add the base path again to a fixed path that already starts with it', () => { + setTestClient(TEST_DSN, { path: '/app/monitor' }); + const client = getCurrentScope().getClient()!; + + setManagedTunnelBasePath(client, '/app'); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); + expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/app/app/monitor' })).toBe(false); + }); + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { setTestClient(TEST_DSN, { basePath: '/custom' }); const client = getCurrentScope().getClient()!; From 4a999a3e9d0b028fb7f93fa5e92fd0af3316f965 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:28:57 -0400 Subject: [PATCH 19/93] fix(core): Don't report a client that drops the tunnel request as an error --- packages/core/src/utils/tunnel.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 48b8f952877f..12f5737b9e09 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -235,7 +235,9 @@ async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promis } const reader = request.body?.getReader(); - const head = reader && (await readEnvelopeHeaderLine(reader)); + // A client that drops the connection mid-upload makes the read throw. That is the client going away, not an app + // error, so it gets the same answer as any other unreadable envelope. + const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); if (!reader || !head) { return new Response('Invalid envelope', { status: 400 }); } From 3017e68011bca3d5ea9e1dc7cc213b3e08a01910 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:24:57 -0400 Subject: [PATCH 20/93] fix(core): Also drop tunnel spans that only carry `http.url`, like SvelteKit's root span --- packages/core/src/client.ts | 10 +++++--- packages/core/src/utils/tunnel.ts | 42 ++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b87a294792ac..621faec3dc49 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,7 +41,7 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; -import { getTunnelIgnoreSpanFilter } from './utils/tunnel'; +import { getTunnelIgnoreSpanFilters } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -272,9 +272,11 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } - const tunnelSpanFilter = options._managedTunnel && getTunnelIgnoreSpanFilter(options._managedTunnel, this._dsn); - if (tunnelSpanFilter) { - this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), tunnelSpanFilter]; + const tunnelSpanFilters = options._managedTunnel + ? getTunnelIgnoreSpanFilters(options._managedTunnel, this._dsn) + : []; + if (tunnelSpanFilters.length) { + this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), ...tunnelSpanFilters]; } const { beforeSendSpan, traceLifecycle } = this._options; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 12f5737b9e09..3e2426886ba2 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -116,6 +116,37 @@ export function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { + const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + return pathPattern ? { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } } : undefined; +} + +/** + * The `ignoreSpans` filters a client installs for tunnel requests. Besides `url.path`, they match the legacy + * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. + */ +export function getTunnelIgnoreSpanFilters( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter[] { + const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); + if (!pathPattern) { + return []; + } + + return [ + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { 'http.url': new RegExp(`^[a-z][a-z\\d+.-]*://[^/]+${pathPattern}(?:[?#].*)?$`) } }, + ]; +} + +/** + * The tunnel paths under the base path, as a regex source matching a pathname with an optional trailing slash. + */ +function getTunnelPathPattern(tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined): string | undefined { const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( escapeStringForRegex, ); @@ -125,8 +156,7 @@ export function getTunnelIgnoreSpanFilter( const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); - // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; + return `${basePath}(?:${paths.join('|')})/?`; } /** @@ -152,9 +182,9 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); - const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); - if (spanFilter) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), spanFilter]; + const spanFilters = getTunnelIgnoreSpanFilters(options._managedTunnel, client?.getDsn()); + if (spanFilters.length) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; } } @@ -394,7 +424,7 @@ function forwardHeaders(request: Request): Record { /** * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock - * out a browser that shares it. It goes last, because React Router can serve only the first path. + * out a browser that shares it. */ function resolveAllowedDsns( allowedDsns: Array | undefined, From c7a419a63c172a567ce5a15ef46e5dc1b20e5c69 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:40:02 -0400 Subject: [PATCH 21/93] feat(core): Add enableManagedTunnel for framework options that enable the tunnel outside init --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 578bc3e949b9..ad62826a3f51 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -104,6 +104,7 @@ export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { applyTunnelDefaults, + enableManagedTunnel, getTunnelPath, getTunnelPaths, handleTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 3e2426886ba2..cbb7eddbfa6f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -188,6 +188,24 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s } } +/** + * Enables the managed tunnel on a client whose `init` didn't, from an option a framework SDK received elsewhere, + * like a build option, and drops the tunnel's spans from then on. A managed tunnel the `init` enabled wins. + */ +export function enableManagedTunnel(client: Client | undefined, tunnelOptions: TunnelOptions): void { + const options = client?.getOptions(); + if (!options || options._managedTunnel) { + return; + } + + options._managedTunnel = tunnelOptions; + + const spanFilters = getTunnelIgnoreSpanFilters(tunnelOptions, client?.getDsn()); + if (spanFilters.length) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; + } +} + /** * Returns true if the request targets the managed tunnel. * From c54586d05406e1d59387e65fb9246d0357afca4f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:55:15 -0400 Subject: [PATCH 22/93] ref(core): Settle a server client's managed tunnel in one place, from init or framework defaults Framework SDKs report tunnel options and the base path once with setManagedTunnelDefaults, and ServerRuntimeClient applies them when it's created, so per-request clients get the span filters before their first span starts. The span filters move out of the base Client into the server client, and the server-only helpers move to @sentry/core/server. --- packages/core/src/client.ts | 8 --- packages/core/src/index.ts | 8 +-- packages/core/src/server-runtime-client.ts | 2 + packages/core/src/server.ts | 8 +++ .../core/src/utils/managedTunnelDefaults.ts | 72 +++++++++++++++++++ packages/core/src/utils/tunnel.ts | 64 ++--------------- packages/core/test/lib/utils/tunnel.test.ts | 72 ++++++++++++++----- 7 files changed, 143 insertions(+), 91 deletions(-) create mode 100644 packages/core/src/utils/managedTunnelDefaults.ts diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 621faec3dc49..c61b3975d41b 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,7 +41,6 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; -import { getTunnelIgnoreSpanFilters } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -272,13 +271,6 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } - const tunnelSpanFilters = options._managedTunnel - ? getTunnelIgnoreSpanFilters(options._managedTunnel, this._dsn) - : []; - if (tunnelSpanFilters.length) { - this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), ...tunnelSpanFilters]; - } - const { beforeSendSpan, traceLifecycle } = this._options; // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, // so a mismatch means it is silently never called. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad62826a3f51..b7172593df2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,18 +101,12 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; -export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; +export type { HandleTunnelRequestOptions } from './utils/tunnel'; export { applyTunnelDefaults, - enableManagedTunnel, getTunnelPath, - getTunnelPaths, handleTunnelRequest, - handleTunnelRequestIfMatched, - isTunnelRequest, normalizeTunnelBasePath, - resolveServerTunnelOption, - setManagedTunnelBasePath, TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 159c4aeb3b93..9dcc4512bea4 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -21,6 +21,7 @@ import { uuid4 } from './utils/misc'; import { makePromiseBuffer } from './utils/promisebuffer'; import { resolvedSyncPromise } from './utils/syncpromise'; import { _getTraceInfoFromScope } from './utils/trace-info'; +import { applyManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -58,6 +59,7 @@ export class ServerRuntimeClient< super(options); + applyManagedTunnelDefaults(this); this._setUpMetricsProcessing(); } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e44bb9c2df8b..c1fe19f4ed7b 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -6,6 +6,14 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; +export type { TunnelRequestLike } from './utils/tunnel'; +export { + getTunnelPaths, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; +export { _INTERNAL_resetManagedTunnelDefaults, setManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts new file mode 100644 index 000000000000..df84bd3be34f --- /dev/null +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -0,0 +1,72 @@ +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, withPathBelowBasePath } from './tunnel'; + +/** + * Managed tunnel settings a framework SDK knows outside `init`. + */ +interface ManagedTunnelDefaults { + /** Enables the managed tunnel, e.g. from TanStack Start's `tunnelRoute` build option. */ + tunnel?: TunnelOptions; + /** The path prefix the app is served under. */ + basePath?: string; +} + +let managedTunnelDefaults: ManagedTunnelDefaults | undefined; + +// The span filters each client got for its managed tunnel, so applying new defaults replaces them. +const installedSpanFilters = new WeakMap(); + +/** + * Sets the managed tunnel settings a framework SDK knows outside `init`, for every server client created afterwards + * and for the current one. Clients created per request, like on Cloudflare, then start with the tunnel and its span + * filters, so not even their first tunnel request's span gets through. A managed tunnel or `basePath` that `init` + * set wins. + * + * Framework SDKs call this once, when their server code loads. + */ +export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { + managedTunnelDefaults = { + ...managedTunnelDefaults, + ...(defaults.tunnel && { tunnel: defaults.tunnel }), + ...(defaults.basePath !== undefined && { basePath: defaults.basePath }), + }; + applyManagedTunnelDefaults(getClient()); +} + +/** + * Settles a server client's managed tunnel: the one its `init` enabled, or else the defaults, under the default base + * path, and installs the span filters for it. Server clients call this when they're created, and + * {@link setManagedTunnelDefaults} calls it again for a client that already exists, replacing the filters it added. + */ +export function applyManagedTunnelDefaults(client: Client | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; + if (!client || !options || !tunnelOptions) { + return; + } + + const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); + const resolved = withPathBelowBasePath( + tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, + ); + options._managedTunnel = resolved; + + const previousFilters = installedSpanFilters.get(client) ?? []; + const spanFilters = getTunnelIgnoreSpanFilters(resolved, client.getDsn()); + options.ignoreSpans = [ + ...(options.ignoreSpans ?? []).filter(filter => !(previousFilters as unknown[]).includes(filter)), + ...spanFilters, + ]; + installedSpanFilters.set(client, spanFilters); +} + +/** + * Resets {@link setManagedTunnelDefaults}, for tests. + * + * @internal + */ +export function _INTERNAL_resetManagedTunnelDefaults(): void { + managedTunnelDefaults = undefined; +} diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index cbb7eddbfa6f..ae45bd4e3958 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,5 +1,4 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; -import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -109,21 +108,8 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { } /** - * An `ignoreSpans` filter for the server spans of tunnel requests. Server SDKs start the span before a framework - * hook gets to answer the request, so clients add this whenever the managed tunnel is enabled. - */ -export function getTunnelIgnoreSpanFilter( - tunnelOptions: TunnelOptions, - dsn: DsnComponents | undefined, -): IgnoreSpanFilter | undefined { - const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); - - // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return pathPattern ? { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } } : undefined; -} - -/** - * The `ignoreSpans` filters a client installs for tunnel requests. Besides `url.path`, they match the legacy + * The `ignoreSpans` filters a server client installs for the server spans of tunnel requests, which server SDKs start + * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. */ export function getTunnelIgnoreSpanFilters( @@ -168,44 +154,6 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { return !path || path.startsWith('/') ? path : `/${path}`; } -/** - * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime - * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. - */ -export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { - const options = client?.getOptions(); - const tunnelOptions = options?._managedTunnel; - const normalizedBasePath = normalizeTunnelBasePath(basePath); - if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { - return; - } - - options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); - - const spanFilters = getTunnelIgnoreSpanFilters(options._managedTunnel, client?.getDsn()); - if (spanFilters.length) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; - } -} - -/** - * Enables the managed tunnel on a client whose `init` didn't, from an option a framework SDK received elsewhere, - * like a build option, and drops the tunnel's spans from then on. A managed tunnel the `init` enabled wins. - */ -export function enableManagedTunnel(client: Client | undefined, tunnelOptions: TunnelOptions): void { - const options = client?.getOptions(); - if (!options || options._managedTunnel) { - return; - } - - options._managedTunnel = tunnelOptions; - - const spanFilters = getTunnelIgnoreSpanFilters(tunnelOptions, client?.getDsn()); - if (spanFilters.length) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; - } -} - /** * Returns true if the request targets the managed tunnel. * @@ -231,12 +179,12 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOpti * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller * can hand the request to the app. */ -export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { - if (!isTunnelRequest(request, options)) { +export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { + if (!isTunnelRequest(request)) { return undefined; } - const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + const allowedDsns = getClient()?.getOptions()._managedTunnel?.allowedDsns; // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes // the browser send directly, instead of losing every envelope to an error response. if (!resolveAllowedDsns(allowedDsns).length) { @@ -457,7 +405,7 @@ function resolveAllowedDsns( * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to * be before the SDK added the base path, would otherwise be served under the base path twice. */ -function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { +export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); return path ? { ...tunnelOptions, path } : tunnelOptions; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index d7dc09ebdad4..ff7e16995dc9 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -7,20 +7,32 @@ import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { applyTunnelDefaults, getTunnelPath, - getTunnelIgnoreSpanFilter, + getTunnelIgnoreSpanFilters, getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, normalizeTunnelBasePath, resolveServerTunnelOption, - setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; -import type { TunnelOptions } from '../../../src/types/options'; +import { + _INTERNAL_resetManagedTunnelDefaults, + applyManagedTunnelDefaults, + setManagedTunnelDefaults, +} from '../../../src/utils/managedTunnelDefaults'; +import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { ServerRuntimeClient } from '../../../src/server-runtime-client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +/** The `url.path` filter, the first of the tunnel span filters. */ +function getTunnelIgnoreSpanFilter( + ...args: Parameters +): IgnoreSpanFilter | undefined { + return getTunnelIgnoreSpanFilters(...args)[0]; +} + function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); @@ -504,9 +516,10 @@ describe('handleTunnelRequestIfMatched', () => { it('forwards a matching request', async () => { fetchMock.mockResolvedValueOnce(new Response('ok')); + setTestClient(TEST_DSN, {}); const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); - const result = await handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] }); + const result = await handleTunnelRequestIfMatched(request); expect(result?.status).toBe(200); expect(fetchMock).toHaveBeenCalledOnce(); @@ -532,14 +545,14 @@ describe('handleTunnelRequestIfMatched', () => { const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); - expect(handleTunnelRequestIfMatched(request, { path: '/monitor' })).toBeUndefined(); expect(request.bodyUsed).toBe(false); }); it('returns undefined without reading the body when the request does not match', () => { + setTestClient(TEST_DSN, {}); const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); - expect(handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] })).toBeUndefined(); + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); expect(request.bodyUsed).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -590,9 +603,9 @@ describe('getTunnelIgnoreSpanFilter', () => { }); }); -describe('client with the managed tunnel enabled', () => { +describe('server client with the managed tunnel enabled', () => { it('ignores the server spans of tunnel requests and keeps user filters', () => { - const client = new TestClient( + const client = new ServerRuntimeClient( getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), ); const ignoreSpans = client.getOptions().ignoreSpans!; @@ -603,10 +616,13 @@ describe('client with the managed tunnel enabled', () => { ).toBe(true); }); - it('leaves ignoreSpans alone without the managed tunnel', () => { - const client = new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN })); - - expect(client.getOptions().ignoreSpans).toBeUndefined(); + it('leaves ignoreSpans alone without the managed tunnel, and in non-server clients', () => { + expect( + new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN })).getOptions().ignoreSpans, + ).toBeUndefined(); + expect( + new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })).getOptions().ignoreSpans, + ).toBeUndefined(); }); }); @@ -681,16 +697,17 @@ describe('normalizeTunnelBasePath', () => { }); }); -describe('setManagedTunnelBasePath', () => { +describe('setManagedTunnelDefaults', () => { afterEach(() => { getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); }); it('serves the tunnel under the base path and drops its spans there', () => { setTestClient(TEST_DSN, {}); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app/'); + setManagedTunnelDefaults({ basePath: '/app/' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); @@ -704,7 +721,7 @@ describe('setManagedTunnelBasePath', () => { setTestClient(TEST_DSN, { path: '/app/monitor' }); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); @@ -714,19 +731,38 @@ describe('setManagedTunnelBasePath', () => { it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { setTestClient(TEST_DSN, { basePath: '/custom' }); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + _INTERNAL_resetManagedTunnelDefaults(); setTestClient(TEST_DSN, {}); const rootClient = getCurrentScope().getClient()!; - setManagedTunnelBasePath(rootClient, '/'); + setManagedTunnelDefaults({ basePath: '/' }); expect(rootClient.getOptions()._managedTunnel).toEqual({}); + _INTERNAL_resetManagedTunnelDefaults(); setTestClient(TEST_DSN); const offClient = getCurrentScope().getClient()!; - setManagedTunnelBasePath(offClient, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(offClient.getOptions()._managedTunnel).toBeUndefined(); }); + + it('enables the tunnel on clients created later, unless their `init` set one', () => { + setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, basePath: '/app' }); + + setTestClient(TEST_DSN); + const laterClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(laterClient); + expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor', basePath: '/app' }); + expect(laterClient.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ path: '/monitor', basePath: '/app' }, makeDsn(TEST_DSN)), + ); + + setTestClient(TEST_DSN, { path: '/from-init' }); + const initClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(initClient); + expect(initClient.getOptions()._managedTunnel).toEqual({ path: '/from-init', basePath: '/app' }); + }); }); function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { From 5a0ddf842da1be8e14d1dd363b3ae4cd4c38ec1b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:45:22 -0400 Subject: [PATCH 23/93] 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 d2896dd3979b..c115d1abb4c0 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 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 bf1178f637783188bff7e9aff6139119937e4367 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:41:20 -0400 Subject: [PATCH 24/93] 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 ff7e16995dc9..c9ba1c069e8a 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'; @@ -626,6 +626,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 9fcc375062e02208e51e3f646dbb2e3c3be91114 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:39:20 -0400 Subject: [PATCH 25/93] 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 12373066e76d42eb73ea33e6d0cd96377842bd16 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 22:22:08 -0400 Subject: [PATCH 26/93] 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 b90465985a14cc79b1a9635aa0889529880f6899 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:45:46 -0400 Subject: [PATCH 27/93] fix(core): Point Next.js users to `tunnelRoute` when the managed tunnel isn't served --- packages/core/src/client.ts | 4 +--- packages/core/src/types/options.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 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.`, ); } diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index c115d1abb4c0..02c9598c6585 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,8 +374,8 @@ export interface ClientOptions Date: Fri, 16 Oct 2026 15:15:26 -0400 Subject: [PATCH 28/93] 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 2f84ea9660d856037a455df32ef3723c17ac797a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:45:03 -0400 Subject: [PATCH 29/93] 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 87198ebec5b8c16abe7b219789a583673ef48d00 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:53:23 -0400 Subject: [PATCH 30/93] 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 4238303c9963b5b2f853178dde78ff2a740b3465 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:06:53 -0400 Subject: [PATCH 31/93] 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 04cf6f08e2643db8c8f0129a3d2cf04f2da242f9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:56:10 -0400 Subject: [PATCH 32/93] 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/src/types/options.ts | 4 +- 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 +- 11 files changed, 23 insertions(+), 81 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/src/types/options.ts b/packages/core/src/types/options.ts index 02c9598c6585..dfdf56788773 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,8 +374,8 @@ export interface ClientOptions { }); }); -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 150e3d3089f7..37ee1af30098 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 328fa666170a658e7ec9c25526414fc69f9da012 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:13:18 -0400 Subject: [PATCH 33/93] feat(tanstackstart-react): Serve the managed tunnel from the server entry The Vite plugin now wraps the app's server entry, so `Sentry.init({ tunnel: true })` works with no build option and tunnel requests are answered before Start's request middleware and routing. Every `tunnelRoute` form is served the same way: `true` uses the DSN-derived path instead of a random one per build, and a static path no longer patches the generated route tree. --- .../tanstackstart-react/tests/tunnel.test.ts | 8 +- .../tanstackstart-react/src/client/index.ts | 7 + .../src/client/tunnelRoute.ts | 4 +- .../tanstackstart-react/src/index.types.ts | 1 + .../tanstackstart-react/src/server/index.ts | 6 +- .../src/server/tunnelRoute.ts | 34 ++- .../src/vite/sentryTanstackStart.ts | 6 +- .../src/vite/tunnelRoute.ts | 211 +++++------------- .../test/client/tunnelRoute.test.ts | 24 ++ .../wrapServerEntryWithSentryTunnel.test.ts | 85 +++++++ .../test/vite/sentryTanstackStart.test.ts | 22 +- .../test/vite/tunnelRoute.test.ts | 181 +++++---------- 12 files changed, 296 insertions(+), 293 deletions(-) create mode 100644 packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index 9857d951b2d8..4995a32542ee 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -10,7 +10,8 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : // The path `tunnelRoute: true` derives from the DSN. + /^\/[a-z0-9]{1,7}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -61,13 +62,12 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(errorEvent.transaction).toBe('/'); }); -test('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => { +test('Only answers POSTs on the managed tunnel path and leaves other methods to the app', async ({ request }) => { test.skip(tunnelRouteMode !== 'static', 'Requires a known managed tunnel path'); const response = await request.get('/monitor'); - expect(response.status()).toBe(405); - expect(response.headers()['allow']).toBe('POST'); + expect(response.status()).toBe(404); }); function pathnameMatchesTunnelRoute(pathname: string): boolean { diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 73c7e4cf7b24..8e9def78a531 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -62,3 +62,10 @@ export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions * can statically import it (the call is server-guarded and tree-shaken from the client bundle). */ export function registerSentryServerTunnelRoute(_path: string): void {} + +/** + * No-op stub for client-side builds. The server entry the Vite plugin wraps only runs on the server. + */ +export function wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { + return entry; +} diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index b22612c123d0..bff595dbaa20 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -1,7 +1,7 @@ import { consoleSandbox } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; -declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | true | undefined; let hasWarnedAboutManagedTunnelRouteOverride = false; @@ -18,7 +18,7 @@ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { } if (options.tunnel) { - if (!hasWarnedAboutManagedTunnelRouteOverride) { + if (options.tunnel !== managedTunnelRoute && !hasWarnedAboutManagedTunnelRouteOverride) { hasWarnedAboutManagedTunnelRouteOverride = true; consoleSandbox(() => { // eslint-disable-next-line no-console diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index a5464d731373..3e3e8fe690ef 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -49,3 +49,4 @@ export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGloba export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; export declare const registerSentryServerTunnelRoute: typeof serverSdk.registerSentryServerTunnelRoute; +export declare const wrapServerEntryWithSentryTunnel: typeof serverSdk.wrapServerEntryWithSentryTunnel; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 2d2b1bb85d26..2d96ca364495 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -9,7 +9,11 @@ export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware'; -export { createSentryTunnelRoute, registerSentryServerTunnelRoute } from './tunnelRoute'; +export { + createSentryTunnelRoute, + registerSentryServerTunnelRoute, + wrapServerEntryWithSentryTunnel, +} from './tunnelRoute'; /** * A no-op stub of the replay integration for the server. Router setup code is shared between client and server, diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 89501d8a2da4..7a89ec338537 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,5 +1,14 @@ import { URL_PATH } from '@sentry/conventions/attributes'; -import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; +import type { TunnelOptions } from '@sentry/core'; +import { + dsnToString, + escapeStringForRegex, + getClient, + getTunnelPaths, + handleTunnelRequest, + handleTunnelRequestIfMatched, +} from '@sentry/core'; +import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -92,3 +101,26 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) }, }; } + +/** + * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this + * to the app's server entry automatically. + */ +export function wrapServerEntryWithSentryTunnel(entry: T, options?: TunnelOptions): T { + // When `init` ran before the server entry loads, drop the tunnel's spans from the first request on. + getTunnelPaths(options).forEach(registerSentryServerTunnelRoute); + + return { + ...entry, + fetch(request: Request, opts?: unknown) { + const tunnelResponse = handleTunnelRequestIfMatched(request, options); + if (tunnelResponse) { + registerSentryServerTunnelRoute(new URL(request.url).pathname); + + return tunnelResponse; + } + + return entry.fetch(request, opts); + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index c3359c077d1b..68b3fab7a2af 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -40,7 +40,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * as the default `tunnel` option on the client. * * You can pass: - * - `true` to generate an opaque route path per dev session or production build. + * - `true` to serve the path derived from the DSN, the same one `Sentry.init({ tunnel: true })` uses. * - `'/custom-path'` to use a fixed static route path. * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN * from the active server Sentry client at runtime. @@ -76,7 +76,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin()]; + const plugins: Plugin[] = [makeRoutePatternPlugin(), makeServerEntryTunnelPlugin(options.tunnelRoute)]; if (options.tunnelRoute) { plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 06f3337b4e5e..f445931ae8c9 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,3 +1,4 @@ +import type { TunnelOptions } from '@sentry/core'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = @@ -14,43 +15,11 @@ export type TunnelRouteOptions = /** * Controls the public route path used by the managed tunnel route. * - * If omitted, an opaque path is generated once per dev session or production build. + * If omitted, the path is derived from the DSN at runtime, the same way `Sentry.init({ tunnel: true })` does. */ path?: string; }; -const MANAGED_TUNNEL_ROUTE_IMPORT = 'SentryManagedTunnelRouteImport'; -const MANAGED_TUNNEL_ROUTE_NAME = 'SentryManagedTunnelRoute'; -const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; - -const VIRTUAL_TUNNEL_ROUTE_ID = 'virtual:sentry-tanstackstart-react/tunnel-route'; -const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; - -function generateRandomTunnelRoute(): string { - const randomPath = Array.from({ length: 8 }, () => Math.floor(Math.random() * 36).toString(36)).join(''); - - return `/${randomPath}`; -} - -export function resolveTunnelRoute(tunnel: true | string): string { - if (typeof tunnel === 'string') { - return tunnel; - } - - if (process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]) { - return process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; - } - - const resolvedTunnelRoute = generateRandomTunnelRoute(); - process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY] = resolvedTunnelRoute; - return resolvedTunnelRoute; -} - -type NormalizedTunnelRouteOptions = { - resolvedPath: string; - allowedDsns: string[] | undefined; -}; - function validateStaticPath(path: string): void { if (!path.startsWith('/') || path.includes('?') || path.includes('#')) { throw new Error( @@ -59,156 +28,90 @@ function validateStaticPath(path: string): void { } } -function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTunnelRouteOptions { +/** + * Maps `tunnelRoute` onto the options `Sentry.init({ tunnel })` takes, so both are served the same way. + */ +export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): TunnelOptions { if (options === true) { - return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; + return {}; } if (typeof options === 'string') { validateStaticPath(options); - return { - resolvedPath: resolveTunnelRoute(options), - allowedDsns: undefined, - }; - } - - const allowedDsns = options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; - const path = options.path; - - if (path) { - validateStaticPath(path); - } - - return { resolvedPath: resolveTunnelRoute(path || true), allowedDsns }; -} - -// `routeTree.gen.ts` quote style follows `tsr.config.json#quoteStyle` (`single` | `double`), -// so we check both forms for each route-identifying key. -const ROUTE_CONFLICT_KEYS = ['fullPath', 'path', 'id'] as const; - -function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { - const literals = [`'${resolvedTunnelRoute}'`, `"${resolvedTunnelRoute}"`]; - return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); -} - -function injectAfterLastImport(source: string, statement: string): string { - const importMatches = [...source.matchAll(/^import .+$/gm)]; - const lastImport = importMatches.at(-1); - - if (lastImport?.index === undefined) { - throw new Error( - '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.', - ); - } - - const insertIndex = lastImport.index + lastImport[0].length; - return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; -} - -export function injectManagedTunnelRoute(source: string, resolvedTunnelRoute: string): string { - if (source.includes(VIRTUAL_TUNNEL_ROUTE_ID)) { - return source; - } - if (hasRouteConflict(source, resolvedTunnelRoute)) { - throw new Error( - `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${resolvedTunnelRoute}" because an existing TanStack Start route already uses that path.`, - ); + return { path: options }; } - const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); - - let transformedSource = injectAfterLastImport( - source, - `import { Route as ${MANAGED_TUNNEL_ROUTE_IMPORT} } from '${VIRTUAL_TUNNEL_ROUTE_ID}'`, - ); - - const rootRouteChildrenMatch = transformedSource.match( - /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, - ); - - if (rootRouteChildrenMatch?.index === undefined) { - throw new Error( - '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.', - ); + if (options.path) { + validateStaticPath(options.path); } - const injectedRootRouteChildrenDeclaration = `const ${MANAGED_TUNNEL_ROUTE_NAME} = ${MANAGED_TUNNEL_ROUTE_IMPORT}.update({ - id: ${serializedTunnelRoute}, - path: ${serializedTunnelRoute}, - getParentRoute: () => rootRouteImport, -} as any) - -${rootRouteChildrenMatch[0]} - ${MANAGED_TUNNEL_ROUTE_NAME}: ${MANAGED_TUNNEL_ROUTE_NAME}, -`; - - transformedSource = `${transformedSource.slice(0, rootRouteChildrenMatch.index)}${injectedRootRouteChildrenDeclaration}${transformedSource.slice(rootRouteChildrenMatch.index + rootRouteChildrenMatch[0].length)}`; - - return transformedSource; + return { + path: options.path || undefined, + allowedDsns: options.allowedDsns?.length ? options.allowedDsns : undefined, + }; } +/** + * Tells the client SDK which tunnel to use. The server entry wrapper serves it. + */ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { - const normalized = normalizeTunnelRouteOptions(options); - const resolvedTunnelRoute = normalized.resolvedPath; - const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); - const serializedAllowedDsns = normalized.allowedDsns ? JSON.stringify(normalized.allowedDsns) : undefined; + const { path } = normalizeTunnelRouteOptions(options); if (debug) { // eslint-disable-next-line no-console - console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); + console.log(`[@sentry/tanstackstart-react] Tunnel route: ${path ?? 'derived from the DSN'}`); } return { name: 'sentry-tanstackstart-tunnel-route', - enforce: 'pre', config() { - return { - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, - }, - }; + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; }, - resolveId(source) { - return source === VIRTUAL_TUNNEL_ROUTE_ID ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID : null; - }, - load(id) { - if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { - return null; - } + }; +} - return `import { createFileRoute } from '@tanstack/react-router'; -import { registerSentryServerTunnelRoute } from '@sentry/tanstackstart-react'; +const SERVER_ENTRY_ALIAS = 'virtual:tanstack-start-server-entry'; +const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; +// The default entry is used directly when the app has no `src/server.ts`, and by Cloudflare's `main`. +const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/(?:esm\/)?server\.[jt]s$/; -// Server-only: drop the tunnel route's own transaction. Registered synchronously at module evaluation -// (before any request) so streamed spans are filtered from the first request. The SSR guard tree-shakes -// it (and the import) out of the client bundle. -if (import.meta.env.SSR) { - registerSentryServerTunnelRoute(${serializedTunnelRoute}); -} +/** + * Wraps the TanStack Start server entry so tunnel requests are answered before Start's own request middleware and + * routing. It does nothing unless the managed tunnel is enabled, either here via `tunnelRoute` or at runtime via + * `Sentry.init({ tunnel: true })`. + */ +export function makeServerEntryTunnelPlugin(tunnelRoute: TunnelRouteOptions | undefined): Plugin { + const tunnelOptions = tunnelRoute ? JSON.stringify(normalizeTunnelRouteOptions(tunnelRoute)) : 'undefined'; + const serverEntryIds = new Set(); -export const Route = createFileRoute(${serializedTunnelRoute})({ - server: { - handlers: { - async POST({ request }) { - const Sentry = await import('@sentry/tanstackstart-react'); - return Sentry.createSentryTunnelRoute(${serializedAllowedDsns ? `{ allowedDsns: ${serializedAllowedDsns} }` : `{}`}).handlers.POST({ request }); - }, - // Without a handler for a method, TanStack Start falls back to SSR-rendering the app (running root loaders). - ANY() { - return new Response(null, { status: 405, headers: { Allow: 'POST' } }); - }, - }, - }, -}); -`; + return { + name: 'sentry-tanstackstart-server-entry-tunnel', + enforce: 'pre', + configResolved(config) { + const aliases = Array.isArray(config.resolve.alias) ? config.resolve.alias : []; + const serverEntry = aliases.find(alias => alias.find === SERVER_ENTRY_ALIAS); + if (serverEntry) { + serverEntryIds.add(normalizePath(serverEntry.replacement)); + } }, - transform(source, id) { - if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { + load(id) { + const normalizedId = normalizePath(id); + if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { return null; } - return injectManagedTunnelRoute(source, resolvedTunnelRoute); + const original = JSON.stringify(`${id}${ORIGINAL_SERVER_ENTRY_QUERY}`); + + return `import entry from ${original}; +import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react'; +export * from ${original}; +export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}); +`; }, }; } + +function normalizePath(id: string): string { + return id.replace(/\\/g, '/'); +} diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index 9dca196f072d..bfafb89cc43d 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -50,6 +50,30 @@ describe('applyTunnelRouteOption()', () => { ); }); + it('turns on the DSN-derived tunnel when the plugin derives the path from the DSN', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + }); + + it('does not warn when the runtime option already matches the managed tunnel', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: true }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('does nothing when no managed tunnel route was injected', async () => { const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts new file mode 100644 index 000000000000..32d7e9b2b3ad --- /dev/null +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -0,0 +1,85 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; +const TUNNEL_URL = `http://localhost:3000${getTunnelPath(DSN)}`; + +function makeEnvelopeRequest(url: string): Request { + return new Request(url, { method: 'POST', body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit }); +} + +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel, ignoreSpans: [] }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('wrapServerEntryWithSentryTunnel', () => { + const appFetch = vi.fn(() => new Response('app')); + const sentryFetch = vi.fn(() => Promise.resolve(new Response('forwarded'))); + + beforeEach(() => { + vi.stubGlobal('fetch', sentryFetch); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('answers the tunnel path when `init` enabled the managed tunnel, without calling the app', async () => { + setClient({}); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('forwarded'); + expect(appFetch).not.toHaveBeenCalled(); + }); + + it('hands every request to the app when the managed tunnel is off', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('app'); + expect(sentryFetch).not.toHaveBeenCalled(); + }); + + it('serves the tunnel with build-time options even when `init` did not enable it', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( + makeEnvelopeRequest(TUNNEL_URL), + ); + + expect(await response.text()).toBe('forwarded'); + }); + + it('passes other requests and their options to the app', async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, { context: 1 }); + + expect(appFetch).toHaveBeenCalledWith(request, { context: 1 }); + }); + + it('ignores spans for the tunnel path from the start when the client already exists', async () => { + // Paths are registered once per module, and earlier tests already registered this one. + vi.resetModules(); + const { wrapServerEntryWithSentryTunnel: freshWrap } = await import('../../src/server/tunnelRoute'); + setClient({}); + const options = getCurrentScope().getClient()!.getOptions(); + + freshWrap({ fetch: appFetch }); + + expect(options.ignoreSpans).toEqual([{ attributes: { 'url.path': expect.any(RegExp) } }]); + expect( + (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( + getTunnelPath(DSN)!, + ), + ).toBe(true); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 0715873cdfa5..7d56837f0499 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; -import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -35,6 +35,12 @@ const mockTunnelRoutePlugin: Plugin = { transform: vi.fn(), }; +const mockServerEntryTunnelPlugin: Plugin = { + name: 'sentry-tanstackstart-server-entry-tunnel', + enforce: 'pre', + load: vi.fn(), +}; + const mockRoutePatternPlugin: Plugin = { name: 'sentry-tanstackstart-route-patterns', enforce: 'pre', @@ -67,6 +73,7 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ vi.mock('../../src/vite/tunnelRoute', () => ({ makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), + makeServerEntryTunnelPlugin: vi.fn(() => mockServerEntryTunnelPlugin), })); describe('sentryTanstackStart()', () => { @@ -85,6 +92,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -97,7 +105,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -108,7 +116,7 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin, mockTunnelRoutePlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -119,6 +127,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -133,6 +142,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -147,6 +157,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -161,6 +172,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -176,6 +188,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -191,6 +204,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -222,6 +236,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -242,6 +257,7 @@ describe('sentryTanstackStart()', () => { sentryTanstackStart(options); expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); + expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute); }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 22b3a9be56a5..a0fb73bc37c4 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,62 +1,22 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { vi } from 'vitest'; -import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; - -const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' - -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -} as any) - -export interface FileRoutesByFullPath { - '/': typeof IndexRoute -} -export interface RootRouteChildren { - IndexRoute: typeof IndexRoute -} - -const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, -} -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) -`; - -const UNTYPED_ROUTE_TREE_SOURCE = ROUTE_TREE_SOURCE.replace( - 'const rootRouteChildren: RootRouteChildren = {', - 'const rootRouteChildren = {', -); +import type { Plugin, ResolvedConfig } from 'vite'; +import { describe, expect, it } from 'vitest'; +import { + makeServerEntryTunnelPlugin, + makeTunnelRoutePlugin, + normalizeTunnelRouteOptions, +} from '../../src/vite/tunnelRoute'; describe('tunnelRoute vite plugin', () => { - beforeEach(() => { - delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; - }); - - afterEach(() => { - delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; - }); - - it('reuses the same generated tunnel route within one process', () => { - const firstTunnelRoute = resolveTunnelRoute(true); - const secondTunnelRoute = resolveTunnelRoute(true); - - expect(firstTunnelRoute).toBe(secondTunnelRoute); - expect(firstTunnelRoute).toMatch(/^\/[a-z0-9]{8}$/); - }); - - it('always generates an 8-character tunnel route', () => { - vi.spyOn(Math, 'random').mockReturnValue(0.5); - - expect(resolveTunnelRoute(true)).toBe('/iiiiiiii'); - }); - - it('returns the provided static tunnel route without reusing a generated one', () => { - resolveTunnelRoute(true); - - expect(resolveTunnelRoute('/monitor')).toBe('/monitor'); + it('maps `tunnelRoute` onto the `tunnel` init options', () => { + expect(normalizeTunnelRouteOptions(true)).toEqual({}); + expect(normalizeTunnelRouteOptions('/monitor')).toEqual({ path: '/monitor' }); + expect(normalizeTunnelRouteOptions({ path: '', allowedDsns: [] })).toEqual({ + path: undefined, + allowedDsns: undefined, + }); + expect( + normalizeTunnelRouteOptions({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }), + ).toEqual({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }); }); it('rejects invalid static tunnel routes', () => { @@ -71,89 +31,60 @@ describe('tunnelRoute vite plugin', () => { ); }); - it('injects the managed tunnel route into the generated TanStack route tree', () => { - const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + it('tells the client the static path, or that the path comes from the DSN', () => { + const staticPlugin = makeTunnelRoutePlugin({ path: '/monitor' }); + const dsnPlugin = makeTunnelRoutePlugin(true); - expect(transformedRouteTree).toContain( - "import { Route as SentryManagedTunnelRouteImport } from 'virtual:sentry-tanstackstart-react/tunnel-route'", - ); - expect(transformedRouteTree).toContain('const SentryManagedTunnelRoute = SentryManagedTunnelRouteImport.update({'); - expect(transformedRouteTree).toContain('id: "/monitor"'); - expect(transformedRouteTree).toContain('path: "/monitor"'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); - expect(transformedRouteTree).toContain('IndexRoute: IndexRoute,'); + expect(staticPlugin.config && staticPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"' }, + }); + expect(dsnPlugin.config && dsnPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 'true' }, + }); }); +}); - it('injects the managed tunnel route when rootRouteChildren is untyped', () => { - const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); +describe('server entry tunnel vite plugin', () => { + const USER_ENTRY = '/app/src/server.ts'; - expect(transformedRouteTree).toContain('const rootRouteChildren = {'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); - }); + function setup(tunnelRoute?: Parameters[0]): (id: string) => unknown { + const plugin = makeServerEntryTunnelPlugin(tunnelRoute); + const config = { + resolve: { alias: [{ find: 'virtual:tanstack-start-server-entry', replacement: USER_ENTRY }] }, + } as unknown as ResolvedConfig; + (plugin.configResolved as (config: ResolvedConfig) => void)(config); - it('fails when the managed tunnel route conflicts with an existing route', () => { - expect(() => injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/')).toThrow( - 'Cannot register managed tunnel route "/" because an existing TanStack Start route already uses that path.', - ); - }); + return id => (plugin.load as Extract unknown>).call({} as never, id); + } - it('fails on route conflict when routeTree.gen.ts uses double quotes (tsr quoteStyle: double)', () => { - const doubleQuotedMonitorTree = ROUTE_TREE_SOURCE.replace("path: '/'", 'path: "/monitor"').replace( - "id: '/'", - 'id: "/monitor"', - ); + it('wraps the app server entry and the default entry', () => { + const load = setup(); - expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( - 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + expect(load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined);'); + expect(load(`${USER_ENTRY}?sentry-tunnel-original`)).toBeNull(); + expect(load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( + 'wrapServerEntryWithSentryTunnel', ); - }); - - it('loads a virtual managed tunnel route module for a static tunnel path', async () => { - const plugin = makeTunnelRoutePlugin({ - allowedDsns: ['http://public@localhost:3031/1337'], - path: '/monitor', - }); - - expect(plugin.config && plugin.config()).toEqual({ - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"', - }, - }); - - expect(plugin.resolveId && plugin.resolveId('virtual:sentry-tanstackstart-react/tunnel-route')).toBe( - '\0virtual:sentry-tanstackstart-react/tunnel-route', + expect(load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts')).toContain( + 'wrapServerEntryWithSentryTunnel', ); - - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); - - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); + expect(load('/app/src/routes/index.tsx')).toBeNull(); }); - it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); - - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + it('re-exports everything from the original server entry', () => { + const load = setup(); - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + expect(load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); }); - it('rejects non-POST requests in the virtual managed tunnel route module', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); - - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); - - expect(virtualRouteModule).toContain('ANY()'); - expect(virtualRouteModule).toContain("status: 405, headers: { Allow: 'POST' }"); + it('passes the build-time options when `tunnelRoute` derives the path from the DSN', () => { + expect(setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {});'); + expect(setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]});', + ); }); - it('treats an empty string `path` like omitted and uses a generated tunnel route', () => { - const plugin = makeTunnelRoutePlugin({ path: '' }); - - const defined = plugin.config && plugin.config(); - const serialized = defined?.define?.__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__; - expect(typeof serialized).toBe('string'); - expect(serialized).toMatch(/^"\/[a-z0-9]{8}"$/); + it('serves a static `tunnelRoute` path from the wrapper too', () => { + expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"});'); }); }); From d89155baac333cc8269e6948be896e9339f1545f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:57:05 -0400 Subject: [PATCH 34/93] feat(tanstackstart-react): Serve the managed tunnel under the base path Start routes on --- .../tests/tunnel.test.ts | 2 +- .../tanstackstart-react/package.json | 9 +++- .../tanstackstart-react/tests/tunnel.test.ts | 13 ++--- .../tanstackstart-react/vite.config.ts | 5 +- .../src/client/tunnelRoute.ts | 14 ++++- .../src/server/tunnelRoute.ts | 22 ++++++-- .../src/vite/sentryTanstackStart.ts | 9 +++- .../src/vite/tunnelRoute.ts | 54 ++++++++++++++++++- .../test/client/tunnelRoute.test.ts | 30 +++++++++++ .../wrapServerEntryWithSentryTunnel.test.ts | 19 +++++++ .../test/vite/sentryTanstackStart.test.ts | 24 +++++++-- .../test/vite/tunnelRoute.test.ts | 50 ++++++++++++++--- 12 files changed, 222 insertions(+), 29 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts index 76a39f44be81..966441835367 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts @@ -10,7 +10,7 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : /^\/[a-z0-9]{1,7}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 555271be7beb..5d46548b1aa6 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,13 +14,15 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "test:assert": "pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", - "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -70,6 +72,11 @@ "label": "tanstackstart-react (tunnel-object)", "build-command": "pnpm test:build:tunnel-object", "assert-command": "pnpm test:assert:tunnel-object" + }, + { + "label": "tanstackstart-react (tunnel-basepath)", + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath" } ] } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index 4995a32542ee..e14011b4a557 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -3,6 +3,7 @@ import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/t const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const basePath = process.env.E2E_TEST_BASE_PATH ?? ''; const expectedTunnelPathMatcher = tunnelRouteMode === 'static' ? '/monitor' @@ -11,7 +12,7 @@ const expectedTunnelPathMatcher = : tunnelRouteMode === 'object' ? '/object-monitor' : // The path `tunnelRoute: true` derives from the DSN. - /^\/[a-z0-9]{1,7}$/; + new RegExp(`^${basePath}/[a-z0-9]{1,7}$`); test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -20,7 +21,7 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; }); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; // Wait for React to hydrate (see __root.tsx) before clicking — the SSR HTML @@ -55,11 +56,11 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); } else { expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); - expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + expect(managedTunnelUrl.pathname).not.toBe(`${basePath}/monitor`); } expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); - expect(errorEvent.transaction).toBe('/'); + expect(errorEvent.transaction).toBe(`${basePath}/`); }); test('Only answers POSTs on the managed tunnel path and leaves other methods to the app', async ({ request }) => { @@ -101,7 +102,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // transaction by the server SDK — tunnel traffic is plumbing, not application requests. const tunnelServerEventPromise = waitForServerHttpSpan(pathnameMatchesTunnelRoute); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; await page.locator('html[data-hydrated="true"]').waitFor(); @@ -127,7 +128,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // this anchor. Racing the two lets us assert the absence of a tunnel transaction without idling on a timeout. const anchorServerEventPromise = waitForServerHttpSpan(pathname => pathname.includes('/api/user/')); - await page.request.get('/api/user/456'); + await page.request.get(`${basePath}/api/user/456`); const winner = await Promise.race([ tunnelServerEventPromise.then(() => 'tunnel' as const), diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 2385d8aa5e93..9e0813a8d701 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -45,7 +45,10 @@ export default defineConfig({ }, plugins: [ tsConfigPaths(), - tanstackStart(), + // Start routes the whole app under the base path, so the tunnel has to be served under it too. + tanstackStart( + process.env.E2E_TEST_BASE_PATH ? { router: { basepath: process.env.E2E_TEST_BASE_PATH } } : undefined, + ), nitro(), // react's vite plugin must come after start's vite plugin viteReact(), diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index bff595dbaa20..d5f92d76f435 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -1,15 +1,25 @@ -import { consoleSandbox } from '@sentry/core'; +import { applyTunnelDefaults, consoleSandbox } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | true | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: string | undefined; let hasWarnedAboutManagedTunnelRouteOverride = false; /** * Applies the managed tunnel route from `sentryTanstackStart({ tunnelRoute: ... })` unless the user already - * configured an explicit runtime `tunnel` option in `Sentry.init()`. + * configured an explicit runtime `tunnel` option in `Sentry.init()`, then serves the tunnel under the base path + * Start routes on. A `basePath` the user set wins. */ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { + applyManagedTunnelRoute(options); + + const basePath = + typeof __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} + +function applyManagedTunnelRoute(options: ReactBrowserOptions): void { const managedTunnelRoute = typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined; diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 7a89ec338537..8340c06ac9d3 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -104,16 +104,30 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) /** * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this - * to the app's server entry automatically. + * to the app's server entry automatically, with the base path Start routes on. */ -export function wrapServerEntryWithSentryTunnel(entry: T, options?: TunnelOptions): T { +export function wrapServerEntryWithSentryTunnel( + entry: T, + options?: TunnelOptions, + basePath?: string, +): T { + // Requests still carry Start's base path here, so the tunnel is matched under it. + const getTunnelOptions = (): TunnelOptions | undefined => { + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + + return tunnelOptions && basePath ? { basePath, ...tunnelOptions } : tunnelOptions; + }; + // When `init` ran before the server entry loads, drop the tunnel's spans from the first request on. - getTunnelPaths(options).forEach(registerSentryServerTunnelRoute); + const startupOptions = getTunnelOptions(); + getTunnelPaths(startupOptions).forEach(path => + registerSentryServerTunnelRoute(`${startupOptions?.basePath?.replace(/\/$/, '') ?? ''}${path}`), + ); return { ...entry, fetch(request: Request, opts?: unknown) { - const tunnelResponse = handleTunnelRequestIfMatched(request, options); + const tunnelResponse = handleTunnelRequestIfMatched(request, getTunnelOptions()); if (tunnelResponse) { registerSentryServerTunnelRoute(new URL(request.url).pathname); diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 68b3fab7a2af..bebffcee875b 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -76,7 +76,12 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin(), makeServerEntryTunnelPlugin(options.tunnelRoute)]; + const tunnelBasePath = makeTunnelBasePathPlugin(); + const plugins: Plugin[] = [ + makeRoutePatternPlugin(), + tunnelBasePath.plugin, + makeServerEntryTunnelPlugin(options.tunnelRoute, tunnelBasePath.getBasePath), + ]; if (options.tunnelRoute) { plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index f445931ae8c9..048c85fa470f 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -71,6 +71,53 @@ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boole }; } +const START_BASE_PATH_DEFINE = 'process.env.TSS_ROUTER_BASEPATH'; + +/** + * Reads the base path TanStack Start routes on and hands it to the client SDK and the server entry wrapper, so the + * managed tunnel is served under it without the user repeating it. Start resolves it from `router.basepath`, or from + * Vite's `base`, and defines it for every environment. This runs after Start's own `config` hook to see that define. + */ +export function makeTunnelBasePathPlugin(): { plugin: Plugin; getBasePath: () => string } { + let basePath = ''; + + const plugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', + enforce: 'post', + config(config) { + const startBasePath = config.define?.[START_BASE_PATH_DEFINE]; + basePath = normalizeBasePath( + typeof startBasePath === 'string' ? parseDefine(startBasePath) : getRouterBasePathFromViteBase(config.base), + ); + + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: JSON.stringify(basePath) } }; + }, + }; + + return { plugin, getBasePath: () => basePath }; +} + +function parseDefine(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); + + return typeof parsed === 'string' ? parsed : undefined; + } catch { + return undefined; + } +} + +// Start's own fallback when `router.basepath` isn't set: an absolute (CDN) `base` doesn't move the app's routes. +function getRouterBasePathFromViteBase(base: string | undefined): string | undefined { + return base && !/^[a-z][a-z\d+.-]*:/i.test(base) && !base.startsWith('//') ? base : undefined; +} + +function normalizeBasePath(basePath: string | undefined): string { + const trimmed = (basePath ?? '').replace(/^\.?\/+|\/+$/g, ''); + + return trimmed ? `/${trimmed}` : ''; +} + const SERVER_ENTRY_ALIAS = 'virtual:tanstack-start-server-entry'; const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; // The default entry is used directly when the app has no `src/server.ts`, and by Cloudflare's `main`. @@ -81,7 +128,10 @@ const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/( * routing. It does nothing unless the managed tunnel is enabled, either here via `tunnelRoute` or at runtime via * `Sentry.init({ tunnel: true })`. */ -export function makeServerEntryTunnelPlugin(tunnelRoute: TunnelRouteOptions | undefined): Plugin { +export function makeServerEntryTunnelPlugin( + tunnelRoute: TunnelRouteOptions | undefined, + getBasePath: () => string = () => '', +): Plugin { const tunnelOptions = tunnelRoute ? JSON.stringify(normalizeTunnelRouteOptions(tunnelRoute)) : 'undefined'; const serverEntryIds = new Set(); @@ -106,7 +156,7 @@ export function makeServerEntryTunnelPlugin(tunnelRoute: TunnelRouteOptions | un return `import entry from ${original}; import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react'; export * from ${original}; -export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}); +export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; }, }; diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index bfafb89cc43d..f1f01669c999 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -85,4 +85,34 @@ describe('applyTunnelRouteOption()', () => { expect(options.tunnel).toBeUndefined(); }); + + it('serves the tunnel under the base path Start routes on, unless the user set one', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const managed: BrowserOptions = { tunnel: true }; + applyTunnelRouteOption(managed); + expect(managed.tunnel).toEqual({ basePath: '/app' }); + + const own: BrowserOptions = { tunnel: { basePath: '/custom' } }; + applyTunnelRouteOption(own); + expect(own.tunnel).toEqual({ basePath: '/custom' }); + + const url: BrowserOptions = { tunnel: 'https://example.com/tunnel' }; + applyTunnelRouteOption(url); + expect(url.tunnel).toBe('https://example.com/tunnel'); + }); + + it('serves a static `tunnelRoute` under the base path too', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = {}; + applyTunnelRouteOption(options); + + expect(options.tunnel).toEqual({ basePath: '/app', path: '/managed-tunnel' }); + }); }); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 32d7e9b2b3ad..4fa539ae296e 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -82,4 +82,23 @@ describe('wrapServerEntryWithSentryTunnel', () => { ), ).toBe(true); }); + + it('answers the tunnel under the base path Start routes on, and only there', async () => { + setClient({}); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/app${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('app'); + }); + + it('keeps a base path the server `init` set', async () => { + setClient({ basePath: '/custom' }); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/custom${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + }); }); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 7d56837f0499..02ffa14e8dd1 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -41,6 +41,10 @@ const mockServerEntryTunnelPlugin: Plugin = { load: vi.fn(), }; +const mockTunnelBasePathPlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', +}; + const mockRoutePatternPlugin: Plugin = { name: 'sentry-tanstackstart-route-patterns', enforce: 'pre', @@ -74,6 +78,7 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ vi.mock('../../src/vite/tunnelRoute', () => ({ makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), makeServerEntryTunnelPlugin: vi.fn(() => mockServerEntryTunnelPlugin), + makeTunnelBasePathPlugin: vi.fn(() => ({ plugin: mockTunnelBasePathPlugin, getBasePath: () => '' })), })); describe('sentryTanstackStart()', () => { @@ -92,6 +97,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -105,7 +111,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -116,7 +122,12 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockServerEntryTunnelPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, + mockTunnelRoutePlugin, + ]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -127,6 +138,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -142,6 +154,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -157,6 +170,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -172,6 +186,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -188,6 +203,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -204,6 +220,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -236,6 +253,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, @@ -257,7 +275,7 @@ describe('sentryTanstackStart()', () => { sentryTanstackStart(options); expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); - expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute); + expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute, expect.any(Function)); }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index a0fb73bc37c4..ba14bf21a07e 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,7 +1,8 @@ -import type { Plugin, ResolvedConfig } from 'vite'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; import { describe, expect, it } from 'vitest'; import { makeServerEntryTunnelPlugin, + makeTunnelBasePathPlugin, makeTunnelRoutePlugin, normalizeTunnelRouteOptions, } from '../../src/vite/tunnelRoute'; @@ -47,8 +48,11 @@ describe('tunnelRoute vite plugin', () => { describe('server entry tunnel vite plugin', () => { const USER_ENTRY = '/app/src/server.ts'; - function setup(tunnelRoute?: Parameters[0]): (id: string) => unknown { - const plugin = makeServerEntryTunnelPlugin(tunnelRoute); + function setup( + tunnelRoute?: Parameters[0], + basePath = '', + ): (id: string) => unknown { + const plugin = makeServerEntryTunnelPlugin(tunnelRoute, () => basePath); const config = { resolve: { alias: [{ find: 'virtual:tanstack-start-server-entry', replacement: USER_ENTRY }] }, } as unknown as ResolvedConfig; @@ -60,7 +64,7 @@ describe('server entry tunnel vite plugin', () => { it('wraps the app server entry and the default entry', () => { const load = setup(); - expect(load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined);'); + expect(load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined, "");'); expect(load(`${USER_ENTRY}?sentry-tunnel-original`)).toBeNull(); expect(load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( 'wrapServerEntryWithSentryTunnel', @@ -78,13 +82,45 @@ describe('server entry tunnel vite plugin', () => { }); it('passes the build-time options when `tunnelRoute` derives the path from the DSN', () => { - expect(setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {});'); + expect(setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {}, "");'); expect(setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( - 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]});', + 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', ); }); it('serves a static `tunnelRoute` path from the wrapper too', () => { - expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"});'); + expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");'); + }); + + it('passes the base path Start routes on to the wrapper', () => { + expect(setup(undefined, '/app')(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, undefined, "/app");', + ); + }); +}); + +describe('tunnel base path vite plugin', () => { + function resolve(config: UserConfig): { define: unknown; basePath: string } { + const { plugin, getBasePath } = makeTunnelBasePathPlugin(); + const result = (plugin.config as (config: UserConfig) => { define: unknown })(config); + + return { define: result.define, basePath: getBasePath() }; + } + + it('reads the base path Start defines for every environment', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/app/') } })).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: '"/app"' }, + basePath: '/app', + }); + }); + + it("falls back to Vite's `base`, ignoring an absolute one", () => { + expect(resolve({ base: '/my-app/' }).basePath).toBe('/my-app'); + expect(resolve({ base: 'https://cdn.example.com/assets/' }).basePath).toBe(''); + }); + + it('serves the tunnel from the root without a base path', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/') } }).basePath).toBe(''); + expect(resolve({}).basePath).toBe(''); }); }); From 1b4a9c59828e17d694799e7add77a01628f8355a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:50:04 -0400 Subject: [PATCH 35/93] fix(tanstackstart-react): Drop the tunnel's spans from the Vite dev server too --- .../tanstackstart-react/package.json | 14 ++++++- .../src/vite/tunnelRoute.ts | 14 ++++++- .../test/vite/tunnelRoute.test.ts | 38 ++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 5d46548b1aa6..9b0bb01e60f0 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -22,7 +22,9 @@ "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", - "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel" + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -77,6 +79,16 @@ "label": "tanstackstart-react (tunnel-basepath)", "build-command": "pnpm test:build:tunnel-basepath", "assert-command": "pnpm test:assert:tunnel-basepath" + }, + { + "label": "tanstackstart-react (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + }, + { + "label": "tanstackstart-react (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 048c85fa470f..0fab1073a862 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,5 +1,7 @@ import type { TunnelOptions } from '@sentry/core'; +import { getClient, getTunnelPaths, setManagedTunnelBasePath } from '@sentry/core'; import type { Plugin } from 'vite'; +import { registerSentryServerTunnelRoute } from '../server/tunnelRoute'; export type TunnelRouteOptions = | true @@ -132,7 +134,8 @@ export function makeServerEntryTunnelPlugin( tunnelRoute: TunnelRouteOptions | undefined, getBasePath: () => string = () => '', ): Plugin { - const tunnelOptions = tunnelRoute ? JSON.stringify(normalizeTunnelRouteOptions(tunnelRoute)) : 'undefined'; + const normalizedTunnelOptions = tunnelRoute ? normalizeTunnelRouteOptions(tunnelRoute) : undefined; + const tunnelOptions = normalizedTunnelOptions ? JSON.stringify(normalizedTunnelOptions) : 'undefined'; const serverEntryIds = new Set(); return { @@ -145,6 +148,15 @@ export function makeServerEntryTunnelPlugin( serverEntryIds.add(normalizePath(serverEntry.replacement)); } }, + // `vite dev` takes each request in this process and passes it on to where the server entry runs, so its + // `http.server` span comes from the SDK `--import` set up here, which the server entry never reaches. + configureServer() { + const basePath = getBasePath(); + setManagedTunnelBasePath(getClient(), basePath); + if (normalizedTunnelOptions) { + getTunnelPaths(normalizedTunnelOptions).forEach(path => registerSentryServerTunnelRoute(`${basePath}${path}`)); + } + }, load(id) { const normalizedId = normalizePath(id); if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index ba14bf21a07e..238f71886e60 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,5 +1,7 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, @@ -124,3 +126,37 @@ describe('tunnel base path vite plugin', () => { expect(resolve({}).basePath).toBe(''); }); }); + +describe('server entry tunnel vite plugin in `vite dev`', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + function setClient(managedTunnel: object | undefined): Record { + const options = { dsn: DSN, _managedTunnel: managedTunnel, ignoreSpans: [] as unknown[] }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + + return options; + } + + it("drops the dev server's spans for a `tunnelRoute` under the base path", () => { + const options = setClient(undefined); + const plugin = makeServerEntryTunnelPlugin(true, () => '/app'); + + (plugin.configureServer as () => void)(); + + const [filter] = options.ignoreSpans as { attributes: Record }[]; + expect(filter?.attributes['url.path']?.test(`/app${getTunnelPath(DSN)}`)).toBe(true); + }); + + it("serves the dev server's managed tunnel from `init` under the base path", () => { + const options = setClient({}); + const plugin = makeServerEntryTunnelPlugin(undefined, () => '/app'); + + (plugin.configureServer as () => void)(); + + expect(options._managedTunnel).toEqual({ basePath: '/app' }); + }); +}); From 0414802427ad7b4254c8bd8f7d8206227454ea24 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:01:49 -0400 Subject: [PATCH 36/93] fix(tanstackstart-react): Normalize the tunnel base path with the core util --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 0fab1073a862..74d14132a6af 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,5 +1,5 @@ import type { TunnelOptions } from '@sentry/core'; -import { getClient, getTunnelPaths, setManagedTunnelBasePath } from '@sentry/core'; +import { getClient, getTunnelPaths, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; import type { Plugin } from 'vite'; import { registerSentryServerTunnelRoute } from '../server/tunnelRoute'; @@ -88,7 +88,7 @@ export function makeTunnelBasePathPlugin(): { plugin: Plugin; getBasePath: () => enforce: 'post', config(config) { const startBasePath = config.define?.[START_BASE_PATH_DEFINE]; - basePath = normalizeBasePath( + basePath = normalizeTunnelBasePath( typeof startBasePath === 'string' ? parseDefine(startBasePath) : getRouterBasePathFromViteBase(config.base), ); @@ -114,12 +114,6 @@ function getRouterBasePathFromViteBase(base: string | undefined): string | undef return base && !/^[a-z][a-z\d+.-]*:/i.test(base) && !base.startsWith('//') ? base : undefined; } -function normalizeBasePath(basePath: string | undefined): string { - const trimmed = (basePath ?? '').replace(/^\.?\/+|\/+$/g, ''); - - return trimmed ? `/${trimmed}` : ''; -} - const SERVER_ENTRY_ALIAS = 'virtual:tanstack-start-server-entry'; const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; // The default entry is used directly when the app has no `src/server.ts`, and by Cloudflare's `main`. From 4d9789c9ce46cdb6162be1f5af811a9c2b9be066 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:08:09 -0400 Subject: [PATCH 37/93] fix(tanstackstart-react): Pass a Worker's env and ctx through the tunnel's server entry wrapper --- .../src/server.ts | 13 +++++++++++- .../tests/tunnel.test.ts | 19 +++++++++++++++++ .../vite.config.ts | 9 +++++++- .../src/server/tunnelRoute.ts | 11 ++++++++-- .../wrapServerEntryWithSentryTunnel.test.ts | 21 +++++++++++++++++++ 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts index b35026939d88..1d2f09c0b10c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts @@ -2,6 +2,8 @@ import * as Sentry from '@sentry/cloudflare'; import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler from '@tanstack/react-start/server-entry'; +const app = wrapFetchWithSentry(handler); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.E2E_TEST_DSN, @@ -9,5 +11,14 @@ export default Sentry.withSentry( tracesSampleRate: 1.0, environment: 'qa', }), - wrapFetchWithSentry(handler), + { + fetch(request, env, ctx) { + // Lets a test check that the Worker's `ctx` survives the Sentry Vite plugin wrapping the server entry. + if (new URL(request.url).pathname === '/test-execution-context') { + return new Response(typeof ctx?.waitUntil); + } + + return (app.fetch as ExportedHandlerFetchHandler)(request, env, ctx); + }, + } satisfies ExportedHandler, ); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..82060b30bd13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; + +test("keeps the Worker's ExecutionContext when the Sentry Vite plugin wraps the server entry", async ({ request }) => { + const response = await request.get('/test-execution-context'); + + expect(await response.text()).toBe('function'); +}); + +test('answers a fixed tunnelRoute with the DSN that `withSentry` initializes', async ({ request }) => { + // A DSN the server doesn't allow, so the tunnel answers without forwarding anywhere. + const envelopeHeader = { dsn: 'https://other@o1.ingest.sentry.io/42', sent_at: new Date().toISOString() }; + const response = await request.post('/monitoring', { + data: `${JSON.stringify(envelopeHeader)}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(403); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts index 8e749133c7d8..b9657a989669 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts @@ -3,10 +3,17 @@ import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react'; import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; export default defineConfig({ server: { port: 3030, }, - plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tsConfigPaths(), tanstackStart(), viteReact()], + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tsConfigPaths(), + tanstackStart(), + viteReact(), + sentryTanstackStart({ tunnelRoute: '/monitoring', sourcemaps: { disable: true } }), + ], }); diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 8340c06ac9d3..11d52796f1ca 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -124,9 +124,16 @@ export function wrapServerEntryWithSentryTunnel( registerSentryServerTunnelRoute(`${startupOptions?.basePath?.replace(/\/$/, '') ?? ''}${path}`), ); + // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served + // by the wrapped default server entry it calls. + if (typeof entry === 'function') { + return entry; + } + return { ...entry, - fetch(request: Request, opts?: unknown) { + // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. + fetch(request: Request, ...rest: unknown[]) { const tunnelResponse = handleTunnelRequestIfMatched(request, getTunnelOptions()); if (tunnelResponse) { registerSentryServerTunnelRoute(new URL(request.url).pathname); @@ -134,7 +141,7 @@ export function wrapServerEntryWithSentryTunnel( return tunnelResponse; } - return entry.fetch(request, opts); + return entry.fetch(request, ...rest); }, }; } diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 4fa539ae296e..94605d4f3b6f 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -66,6 +66,27 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(appFetch).toHaveBeenCalledWith(request, { context: 1 }); }); + it("passes every argument to the app, like a Cloudflare Worker's `env` and `ctx`", async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + const env = { E2E_TEST_DSN: DSN }; + const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, env, ctx); + + expect(appFetch).toHaveBeenCalledWith(request, env, ctx); + }); + + it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { + class Worker { + public fetch(): Response { + return new Response('app'); + } + } + + expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); + }); + it('ignores spans for the tunnel path from the start when the client already exists', async () => { // Paths are registered once per module, and earlier tests already registered this one. vi.resetModules(); From fcdb6e6ef8546c6902097235fca114341bcf13dc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:37:29 -0400 Subject: [PATCH 38/93] fix(tanstackstart-react): Fail the build when a fixed tunnelRoute collides with an app route, as on develop --- .../src/vite/tunnelRoute.ts | 20 +++++++++++++++++ .../test/vite/tunnelRoute.test.ts | 22 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 74d14132a6af..5e370809b08a 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -54,6 +54,15 @@ export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): Tunnel }; } +// `routeTree.gen.ts` quote style follows `tsr.config.json#quoteStyle` (`single` | `double`), +// so we check both forms for each route-identifying key. +const ROUTE_CONFLICT_KEYS = ['fullPath', 'path', 'id'] as const; + +function hasRouteConflict(source: string, tunnelPath: string): boolean { + const literals = [`'${tunnelPath}'`, `"${tunnelPath}"`]; + return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); +} + /** * Tells the client SDK which tunnel to use. The server entry wrapper serves it. */ @@ -70,6 +79,17 @@ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boole config() { return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; }, + // The server entry wrapper answers POSTs to a fixed path before Start's routing, so an app route on that path + // would silently lose them. + transform(source, id) { + if (path && /[\\/]routeTree\.gen\.ts$/.test(id) && hasRouteConflict(source, path)) { + throw new Error( + `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${path}" because an existing TanStack Start route already uses that path.`, + ); + } + + return null; + }, }; } diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 238f71886e60..e0f734de68a2 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -45,6 +45,28 @@ describe('tunnelRoute vite plugin', () => { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 'true' }, }); }); + + it('fails when a static tunnel route conflicts with an existing route', () => { + const transform = (path: string, source: string): unknown => + (makeTunnelRoutePlugin(path).transform as (source: string, id: string) => unknown)( + source, + '/app/src/routeTree.gen.ts', + ); + const singleQuoted = "const MonitorRoute = MonitorRouteImport.update({\n id: '/monitor',\n path: '/monitor',"; + const doubleQuoted = singleQuoted.replace(/'/g, '"'); + + expect(() => transform('/monitor', singleQuoted)).toThrow( + 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + ); + expect(() => transform('/monitor', doubleQuoted)).toThrow('already uses that path'); + expect(transform('/other', singleQuoted)).toBeNull(); + expect( + (makeTunnelRoutePlugin(true).transform as (s: string, id: string) => unknown)( + singleQuoted, + '/app/src/routeTree.gen.ts', + ), + ).toBeNull(); + }); }); describe('server entry tunnel vite plugin', () => { From ab02b21378155cf6b7603b4260a7028e4c657936 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:29:20 -0400 Subject: [PATCH 39/93] docs(tanstackstart-react): Describe how tunnelRoute is served now --- .../tanstackstart-react/src/vite/sentryTanstackStart.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index bebffcee875b..507b9069c7c5 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -36,14 +36,13 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { /** * Configures a framework-managed same-origin tunnel route for Sentry envelopes. * - * This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path - * as the default `tunnel` option on the client. + * The server entry answers the tunnel before Start's request middleware and routing, and the resulting path is + * applied as the default `tunnel` option on the client. * * You can pass: * - `true` to serve the path derived from the DSN, the same one `Sentry.init({ tunnel: true })` uses. * - `'/custom-path'` to use a fixed static route path. - * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN - * from the active server Sentry client at runtime. + * - `{ allowedDsns, path }` for full control. The DSN of the active server Sentry client is always allowed too. * * If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because * the managed tunnel route is being bypassed. From a64d809d38719f4be4dd4c0106949b7fa41a86a3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:40:21 -0400 Subject: [PATCH 40/93] ref(tanstackstart-react): Enable tunnelRoute on the client like init and serve it from there --- .../src/server/tunnelRoute.ts | 39 +++++++++---------- .../src/vite/tunnelRoute.ts | 7 ++-- .../wrapServerEntryWithSentryTunnel.test.ts | 23 +++++++---- 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 11d52796f1ca..94d323ef1bab 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -2,11 +2,12 @@ import { URL_PATH } from '@sentry/conventions/attributes'; import type { TunnelOptions } from '@sentry/core'; import { dsnToString, + enableManagedTunnel, escapeStringForRegex, getClient, - getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, + setManagedTunnelBasePath, } from '@sentry/core'; import type { ServerEntry } from './wrapFetchWithSentry'; @@ -104,25 +105,28 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) /** * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this - * to the app's server entry automatically, with the base path Start routes on. + * to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes on. + * + * `tunnelRoute` enables the managed tunnel on the client the same way `Sentry.init({ tunnel })` does, unless that + * `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. */ export function wrapServerEntryWithSentryTunnel( entry: T, - options?: TunnelOptions, + tunnelRoute?: TunnelOptions, basePath?: string, ): T { - // Requests still carry Start's base path here, so the tunnel is matched under it. - const getTunnelOptions = (): TunnelOptions | undefined => { - const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; - - return tunnelOptions && basePath ? { basePath, ...tunnelOptions } : tunnelOptions; + // Requests still carry Start's base path here, so the tunnel is served under it. + const applyToClient = (): void => { + const client = getClient(); + if (tunnelRoute) { + enableManagedTunnel(client, basePath ? { basePath, ...tunnelRoute } : tunnelRoute); + } + setManagedTunnelBasePath(client, basePath); }; - // When `init` ran before the server entry loads, drop the tunnel's spans from the first request on. - const startupOptions = getTunnelOptions(); - getTunnelPaths(startupOptions).forEach(path => - registerSentryServerTunnelRoute(`${startupOptions?.basePath?.replace(/\/$/, '') ?? ''}${path}`), - ); + // `init` usually runs from `--import` before the server entry loads, so the tunnel's spans are dropped from the + // first request on. On Cloudflare the client is created per request, which this repeats for. + applyToClient(); // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served // by the wrapped default server entry it calls. @@ -134,14 +138,9 @@ export function wrapServerEntryWithSentryTunnel( ...entry, // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. fetch(request: Request, ...rest: unknown[]) { - const tunnelResponse = handleTunnelRequestIfMatched(request, getTunnelOptions()); - if (tunnelResponse) { - registerSentryServerTunnelRoute(new URL(request.url).pathname); - - return tunnelResponse; - } + applyToClient(); - return entry.fetch(request, ...rest); + return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); }, }; } diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 5e370809b08a..e539958aff05 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,7 +1,6 @@ import type { TunnelOptions } from '@sentry/core'; -import { getClient, getTunnelPaths, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; +import { enableManagedTunnel, getClient, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; import type { Plugin } from 'vite'; -import { registerSentryServerTunnelRoute } from '../server/tunnelRoute'; export type TunnelRouteOptions = | true @@ -166,10 +165,10 @@ export function makeServerEntryTunnelPlugin( // `http.server` span comes from the SDK `--import` set up here, which the server entry never reaches. configureServer() { const basePath = getBasePath(); - setManagedTunnelBasePath(getClient(), basePath); if (normalizedTunnelOptions) { - getTunnelPaths(normalizedTunnelOptions).forEach(path => registerSentryServerTunnelRoute(`${basePath}${path}`)); + enableManagedTunnel(getClient(), basePath ? { basePath, ...normalizedTunnelOptions } : normalizedTunnelOptions); } + setManagedTunnelBasePath(getClient(), basePath); }, load(id) { const normalizedId = normalizePath(id); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 94605d4f3b6f..a04bb8558802 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -87,16 +87,13 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); }); - it('ignores spans for the tunnel path from the start when the client already exists', async () => { - // Paths are registered once per module, and earlier tests already registered this one. - vi.resetModules(); - const { wrapServerEntryWithSentryTunnel: freshWrap } = await import('../../src/server/tunnelRoute'); - setClient({}); + it('enables `tunnelRoute` on the client like `init` would, dropping its spans from the start', () => { + setClient(undefined); const options = getCurrentScope().getClient()!.getOptions(); - freshWrap({ fetch: appFetch }); + wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}); - expect(options.ignoreSpans).toEqual([{ attributes: { 'url.path': expect.any(RegExp) } }]); + expect(options._managedTunnel).toEqual({}); expect( (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( getTunnelPath(DSN)!, @@ -104,6 +101,18 @@ describe('wrapServerEntryWithSentryTunnel', () => { ).toBe(true); }); + it('keeps the managed tunnel `init` enabled over `tunnelRoute`', async () => { + setClient({ path: '/from-init' }); + const options = getCurrentScope().getClient()!.getOptions(); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, { path: '/from-build' }).fetch( + makeEnvelopeRequest('http://localhost:3000/from-init'), + ); + + expect(options._managedTunnel).toEqual({ path: '/from-init' }); + expect(await response.text()).toBe('forwarded'); + }); + it('answers the tunnel under the base path Start routes on, and only there', async () => { setClient({}); const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); From 76dfbbbe171f4814e7d7b7890196f135640906eb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:56:54 -0400 Subject: [PATCH 41/93] ref(tanstackstart-react): Report tunnelRoute and the base path once with setManagedTunnelDefaults --- .../src/server/tunnelRoute.ts | 32 ++++--------------- .../src/vite/tunnelRoute.ts | 9 ++---- .../wrapServerEntryWithSentryTunnel.test.ts | 2 ++ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 94d323ef1bab..dc5cdb82ee58 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,14 +1,7 @@ import { URL_PATH } from '@sentry/conventions/attributes'; import type { TunnelOptions } from '@sentry/core'; -import { - dsnToString, - enableManagedTunnel, - escapeStringForRegex, - getClient, - handleTunnelRequest, - handleTunnelRequestIfMatched, - setManagedTunnelBasePath, -} from '@sentry/core'; +import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -107,26 +100,17 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this * to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes on. * - * `tunnelRoute` enables the managed tunnel on the client the same way `Sentry.init({ tunnel })` does, unless that - * `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. + * `tunnelRoute` enables the managed tunnel on every server client the same way `Sentry.init({ tunnel })` does, unless + * that `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. */ export function wrapServerEntryWithSentryTunnel( entry: T, tunnelRoute?: TunnelOptions, basePath?: string, ): T { - // Requests still carry Start's base path here, so the tunnel is served under it. - const applyToClient = (): void => { - const client = getClient(); - if (tunnelRoute) { - enableManagedTunnel(client, basePath ? { basePath, ...tunnelRoute } : tunnelRoute); - } - setManagedTunnelBasePath(client, basePath); - }; - - // `init` usually runs from `--import` before the server entry loads, so the tunnel's spans are dropped from the - // first request on. On Cloudflare the client is created per request, which this repeats for. - applyToClient(); + // Runs when the server entry loads, before any request. Requests still carry Start's base path here, so the tunnel + // is served under it. + setManagedTunnelDefaults({ tunnel: tunnelRoute, basePath }); // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served // by the wrapped default server entry it calls. @@ -138,8 +122,6 @@ export function wrapServerEntryWithSentryTunnel( ...entry, // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. fetch(request: Request, ...rest: unknown[]) { - applyToClient(); - return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); }, }; diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index e539958aff05..56cb12a90cc5 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,5 +1,6 @@ import type { TunnelOptions } from '@sentry/core'; -import { enableManagedTunnel, getClient, normalizeTunnelBasePath, setManagedTunnelBasePath } from '@sentry/core'; +import { normalizeTunnelBasePath } from '@sentry/core'; +import { setManagedTunnelDefaults } from '@sentry/core/server'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = @@ -164,11 +165,7 @@ export function makeServerEntryTunnelPlugin( // `vite dev` takes each request in this process and passes it on to where the server entry runs, so its // `http.server` span comes from the SDK `--import` set up here, which the server entry never reaches. configureServer() { - const basePath = getBasePath(); - if (normalizedTunnelOptions) { - enableManagedTunnel(getClient(), basePath ? { basePath, ...normalizedTunnelOptions } : normalizedTunnelOptions); - } - setManagedTunnelBasePath(getClient(), basePath); + setManagedTunnelDefaults({ tunnel: normalizedTunnelOptions, basePath: getBasePath() }); }, load(id) { const normalizedId = normalizePath(id); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index a04bb8558802..1c299a135705 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -1,5 +1,6 @@ import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { _INTERNAL_resetManagedTunnelDefaults } from '@sentry/core/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; @@ -25,6 +26,7 @@ describe('wrapServerEntryWithSentryTunnel', () => { afterEach(() => { getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); From 2689a18ceaeaa72e7c4192decdb62bdca68903da Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:33:20 -0400 Subject: [PATCH 42/93] test: Check that runtimes drop the server spans of managed tunnel requests Meta-framework SDKs answer the tunnel requests. Cloudflare, Node, Bun and Deno only translate `tunnel: true` so the core client drops the server span they create for them, which these tests pin down. --- .../tracing/managed-tunnel/instrument.mjs | 11 +++++ .../suites/tracing/managed-tunnel/server.mjs | 10 +++++ .../suites/tracing/managed-tunnel/test.ts | 28 ++++++++++++ packages/bun/test/managedTunnel.test.ts | 44 +++++++++++++++++++ packages/cloudflare/test/request.test.ts | 40 +++++++++++++++++ packages/deno/test/deno-serve-tunnel.test.ts | 42 ++++++++++++++++++ 6 files changed, 175 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts create mode 100644 packages/bun/test/managedTunnel.test.ts create mode 100644 packages/deno/test/deno-serve-tunnel.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs new file mode 100644 index 000000000000..df798c3e0249 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + // A meta-framework answers the tunnel requests, Node only drops their server spans. + tunnel: true, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs new file mode 100644 index 000000000000..ee4a7683d045 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs @@ -0,0 +1,10 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http from 'http'; + +const server = http.createServer((_request, response) => { + response.end('app'); +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts new file mode 100644 index 000000000000..ea8a1db7d0c4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -0,0 +1,28 @@ +import { getTunnelPath } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('managed tunnel', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('drops the server span of tunnel requests', async () => { + const runner = createRunner() + .expect({ + span: container => { + // Spans flush in order, so a span for the tunnel request would arrive before this one. + const serverSpan = container.items.find(item => item.is_segment); + expect(serverSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/after-tunnel' }); + }, + }) + .start(); + + await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!); + await runner.makeRequest('get', '/after-tunnel'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/bun/test/managedTunnel.test.ts b/packages/bun/test/managedTunnel.test.ts new file mode 100644 index 000000000000..7523cf0a711c --- /dev/null +++ b/packages/bun/test/managedTunnel.test.ts @@ -0,0 +1,44 @@ +import type { TransactionEvent } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { init } from '../src'; + +const DSN = 'https://username@domain/123'; + +describe('managed tunnel', () => { + afterEach(() => { + SentryCore.getCurrentScope().setClient(undefined); + }); + + test('Bun.serve drops the server span of tunnel requests', async () => { + const transactions: string[] = []; + init({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + // A meta-framework answers the tunnel requests, Bun only drops their server spans. + tunnel: true, + beforeSendTransaction: (event: TransactionEvent) => { + transactions.push(event.request?.url ?? ''); + return null; + }, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), + }); + + const server = Bun.serve({ port: 0, fetch: () => new Response('app') }); + const baseUrl = `http://localhost:${server.port}`; + + const tunnelResponse = await fetch(`${baseUrl}${SentryCore.getTunnelPath(DSN)}`, { + method: 'POST', + body: 'envelope', + }); + expect(await tunnelResponse.text()).toBe('app'); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + await SentryCore.flush(); + server.stop(); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.endsWith('/after-tunnel')).toBe(true); + }); +}); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index ae30155c3c6a..b4118c0a8c3d 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -63,6 +63,46 @@ describe('withSentry', () => { }); }); + describe('managed tunnel', () => { + async function transactionsFor(url: string, tunnel?: true): Promise { + const transactions: string[] = []; + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tunnel, + tracesSampleRate: 1, + beforeSendTransaction(event) { + transactions.push(event.transaction ?? ''); + return null; + }, + }, + request: new Request(url, { method: 'POST' }), + context: createMockExecutionContext(), + }, + () => new Response('app'), + ).then(response => response.text()); + + // Wait for async span end and transaction capture + await new Promise(resolve => setTimeout(resolve, 50)); + + return transactions; + } + + test('leaves tunnel requests to the app and drops their server span', async () => { + const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; + + expect(await transactionsFor(tunnelUrl, true)).toEqual([]); + expect(await transactionsFor('https://example.com/api', true)).toEqual(['POST /api']); + }); + + test('keeps the span of the tunnel path when the managed tunnel is off', async () => { + const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; + + expect(await transactionsFor(tunnelUrl)).toHaveLength(1); + }); + }); + test('passes through the response from the handler', async () => { const response = new Response('test'); const result = await wrapRequestHandler( diff --git a/packages/deno/test/deno-serve-tunnel.test.ts b/packages/deno/test/deno-serve-tunnel.test.ts new file mode 100644 index 000000000000..67cd1e61c989 --- /dev/null +++ b/packages/deno/test/deno-serve-tunnel.test.ts @@ -0,0 +1,42 @@ +// + +import type { TransactionEvent } from '@sentry/core'; +import { getMainCarrier, getTunnelPath } from '@sentry/core'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { init } from '../build/esm/index.js'; + +const DSN = 'https://username@domain/123'; + +Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => { + getMainCarrier().__SENTRY__ = undefined; + const transactions: string[] = []; + + init({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + // A meta-framework answers the tunnel requests, Deno only drops their server spans. + tunnel: true, + beforeSendTransaction: (event: TransactionEvent) => { + transactions.push(event.request?.url ?? ''); + return null; + }, + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => new Response('app')); + await listening; + + const baseUrl = `http://localhost:${server.addr.port}`; + const tunnelResponse = await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' }); + assertEquals(await tunnelResponse.text(), 'app'); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + + abortController.abort(); + await server.finished; + + assertEquals(transactions.length, 1); + assertEquals(transactions[0]?.endsWith('/after-tunnel'), true); +}); From 934f55efcce6d984f76e5098d8cbb7d32721696b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:14:04 -0400 Subject: [PATCH 43/93] test(bun): Run the managed tunnel suite only with the Bun SDK --- dev-packages/bun-integration-tests/node-suites/excludes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 65edcd311712..5ca029909329 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -25,6 +25,7 @@ const NO_HTTP_SERVER_SPANS = [ 'suites/tracing/httpIntegration/test.ts', 'suites/tracing/httpServerSpans-unrouted/test.ts', 'suites/tracing/ignoreSpans/**', + 'suites/tracing/managed-tunnel/test.ts', 'suites/tracing/meta-tags-twp-errors/test.ts', 'suites/tracing/meta-tags/test.ts', 'suites/tracing/requestData/test.ts', From 2fbbe93f2108e52272c539e16bd18f5f6b887f18 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:40:36 -0400 Subject: [PATCH 44/93] test(node): Assert the tunnel request sends no server span at all --- .../suites/tracing/managed-tunnel/test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts index ea8a1db7d0c4..1504ba345677 100644 --- a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -1,3 +1,4 @@ +import { URL_PATH } from '@sentry/conventions/attributes'; import { getTunnelPath } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -12,9 +13,9 @@ describe('managed tunnel', () => { const runner = createRunner() .expect({ span: container => { - // Spans flush in order, so a span for the tunnel request would arrive before this one. - const serverSpan = container.items.find(item => item.is_segment); - expect(serverSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/after-tunnel' }); + // Spans flush in order, so a span for the tunnel request would arrive in this container or before it. + const segmentPaths = container.items.filter(item => item.is_segment).map(item => item.attributes[URL_PATH]); + expect(segmentPaths).toEqual([{ type: 'string', value: '/after-tunnel' }]); }, }) .start(); From 366bdb7052b23ac0509fd21b967a57eacf51442d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:33:29 -0400 Subject: [PATCH 45/93] feat(sveltekit): Serve the managed tunnel from `sentryHandle` under the app's base path `sentryHandle` answers tunnel requests before isolation, tracing and routing. `sentrySvelteKit()` hands `paths.base` to the client and server SDKs, so `tunnel: true` works under a base path without repeating it. --- .../package.json | 13 ++++- .../src/app.d.ts | 2 + .../src/hooks.client.ts | 2 +- .../src/hooks.server.ts | 2 +- .../tests/tunnel.test.ts | 43 +++++++++++++++++ .../vite.config.js | 7 +++ .../sveltekit-3/package.json | 15 +++++- .../sveltekit-3/src/app.d.ts | 2 + .../sveltekit-3/src/hooks.client.ts | 2 +- .../sveltekit-3/src/instrumentation.server.ts | 2 +- .../sveltekit-3/tests/tunnel.test.ts | 45 +++++++++++++++++ .../sveltekit-3/vite.config.js | 7 +++ packages/sveltekit/src/client/sdk.ts | 2 + packages/sveltekit/src/common/tunnel.ts | 18 +++++++ .../sveltekit/src/server-common/handle.ts | 6 +++ packages/sveltekit/src/server/sdk.ts | 2 + .../sveltekit/src/vite/sentryVitePlugins.ts | 2 + packages/sveltekit/src/vite/tunnelBasePath.ts | 46 ++++++++++++++++++ packages/sveltekit/src/worker/cloudflare.ts | 2 + packages/sveltekit/test/common/tunnel.test.ts | 48 +++++++++++++++++++ .../test/server-common/handle.test.ts | 38 +++++++++++++++ .../test/vite/sentrySvelteKitPlugins.test.ts | 16 ++++--- .../test/vite/tunnelBasePath.test.ts | 44 +++++++++++++++++ 23 files changed, 352 insertions(+), 14 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts create mode 100644 packages/sveltekit/src/common/tunnel.ts create mode 100644 packages/sveltekit/src/vite/tunnelBasePath.ts create mode 100644 packages/sveltekit/test/common/tunnel.test.ts create mode 100644 packages/sveltekit/test/vite/tunnelBasePath.test.ts diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json index 1844e0e7b5cd..6f8a4665e604 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json @@ -11,7 +11,9 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:e2e": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:e2e" + "test:assert": "pnpm test:e2e", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -30,5 +32,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3-cloudflare-workers (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts index 4fc6bc229fa5..ea92ff23f5d3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts @@ -1,6 +1,8 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // `App.Platform` (including `platform.ctx`) is declared by `@sveltejs/adapter-cloudflare`. declare global { + const __E2E_TEST_TUNNEL__: boolean; + namespace App { // interface Error {} // interface Locals {} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts index ba1d3dfa2be0..2c640017c085 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', dsn: PUBLIC_E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts index 1b8dd4528c36..5fd10e38d8ab 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts @@ -10,7 +10,7 @@ export const handle = sequence( environment: 'qa', // dynamic sampling bias to keep spans dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }), sentryHandle(), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts new file mode 100644 index 000000000000..b81ae9c2b575 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +test('serves the managed tunnel from the Worker under the base path', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => getSpanOp(span) === 'pageload' && span.is_segment, + ); + const tunnelServerSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/app/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + await expect(pageloadSpanPromise).resolves.toBeDefined(); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '').startsWith('/app/server-load-error'), + ); + await page.request.get('/app/server-load-error'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js index f83d55aa81e8..4f950627ee7e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js @@ -4,7 +4,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(tunnelVariant), + }, plugins: [ sentrySvelteKit({ autoUploadSourceMaps: false, @@ -17,6 +23,7 @@ export default defineConfig({ prerender: { handleHttpError: 'ignore', }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 7bf8da3e4e00..90a8de3cc012 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -12,7 +12,9 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "pnpm test:prod", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -31,5 +33,14 @@ "volta": { "extends": "../../package.json" }, - "type": "module" + "type": "module", + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3 (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + } + ] + } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts index ede601ab93e2..bcf9f139abc1 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts @@ -1,6 +1,8 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { + const __E2E_TEST_TUNNEL__: boolean; + namespace App { // interface Error {} // interface Locals {} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts index 4d4723a527d3..1c9774fda5d2 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts index 18c0b65b6db7..c0041a8ffe8e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts @@ -7,6 +7,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..9da2f72ddb26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +test('sends client events through the managed tunnel under the base path', async ({ page }) => { + const errorEventPromise = waitForError( + 'sveltekit-3', + event => event?.exception?.values?.[0]?.value === 'Click Error', + ); + const tunnelServerSpanPromise = waitForStreamedSpan( + 'sveltekit-3', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + + // The pageload envelope is the first tunnel request, and it's only sent once the app hydrated. + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + await page.goto('/app/client-error'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + await page.getByText('Throw error').click(); + await expect(errorEventPromise).resolves.toBeDefined(); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'sveltekit-3', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '').startsWith('/app/users'), + ); + await page.request.get('/app/users/123'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js index 3e789da133e5..19109a90b019 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js @@ -4,7 +4,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(tunnelVariant), + }, plugins: [ sentrySvelteKit({ autoUploadSourceMaps: false, @@ -22,6 +28,7 @@ export default defineConfig({ tracing: { server: true, }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index a6294ad25977..4c1dc3c2edc6 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/svelte'; import { getDefaultIntegrations as getDefaultSvelteIntegrations, init as initSvelteSdk, WINDOW } from '@sentry/svelte'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration as svelteKitBrowserTracingIntegration } from './browserTracingIntegration'; type WindowWithSentryFetchProxy = typeof WINDOW & { @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'svelte']); + applyTunnelBasePath(opts); // 1. Switch window.fetch to our fetch proxy we injected earlier const actualFetch = switchToFetchProxy(); diff --git a/packages/sveltekit/src/common/tunnel.ts b/packages/sveltekit/src/common/tunnel.ts new file mode 100644 index 000000000000..f6d023c21b41 --- /dev/null +++ b/packages/sveltekit/src/common/tunnel.ts @@ -0,0 +1,18 @@ +import type { Options } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; + +/** Set by `sentrySvelteKit()` to the app's `paths.base`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_sveltekit_base_path'; + +/** + * Serves the managed tunnel under the app's base path, which SvelteKit requires for every request it handles. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + const basePath = (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL]; + if (!basePath || !options.tunnel || typeof options.tunnel === 'string') { + return; + } + + options.tunnel = { basePath, ...(options.tunnel === true ? {} : options.tunnel) }; +} diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index aa78ab6da7b8..13bceea99cb0 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -7,6 +7,7 @@ import { getDefaultIsolationScope, getIsolationScope, getTraceMetaTags, + handleTunnelRequestIfMatched, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -291,6 +292,11 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { + const tunnelResponse = handleTunnelRequestIfMatched(input.event.request); + if (tunnelResponse) { + return tunnelResponse; + } + const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent; // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts index 75fe3fcc9021..8500511815a3 100644 --- a/packages/sveltekit/src/server/sdk.ts +++ b/packages/sveltekit/src/server/sdk.ts @@ -1,6 +1,7 @@ import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getDefaultNodeIntegrations, init as initNodeSdk } from '@sentry/node'; +import { applyTunnelBasePath } from '../common/tunnel'; import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration'; import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans'; import { httpIntegration } from './integrations/http'; @@ -26,6 +27,7 @@ export function init(options: NodeOptions): NodeClient | undefined { }; applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'node']); + applyTunnelBasePath(opts); return initNodeSdk(opts); } diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index d4bf6ead91ec..b0fe5739d283 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -11,6 +11,7 @@ import { makeGlobalValuesInjectionPlugin } from './injectGlobalValues'; import { createKitConfigResolver } from './kitConfig'; import { makeCustomSentryVitePlugins } from './sourceMaps'; import { getAdapterOutputDir } from './svelteConfig'; +import { makeTunnelBasePathPlugin } from './tunnelBasePath'; import type { CustomSentryVitePluginOptions, SentrySvelteKitPluginOptions } from './types'; const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = { @@ -56,6 +57,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} kitConfigResolver.plugin, makeBrowserTracingVariantResolverPlugin(), makeOpenTelemetryApiResolverPlugin(), + makeTunnelBasePathPlugin(getKitConfig), ]; if (mergedOptions.autoInstrument) { diff --git a/packages/sveltekit/src/vite/tunnelBasePath.ts b/packages/sveltekit/src/vite/tunnelBasePath.ts new file mode 100644 index 000000000000..0f8f81923ffe --- /dev/null +++ b/packages/sveltekit/src/vite/tunnelBasePath.ts @@ -0,0 +1,46 @@ +import { escapeStringForRegex } from '@sentry/core'; +import type { Plugin } from 'vite'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; +import type { ResolvedKitConfig } from './kitConfig'; +import { getHooksFileName } from './svelteConfig'; + +const VIRTUAL_BASE_PATH_FILE = '\0sentry-sveltekit-tunnel-base-path'; + +/** + * Hands the app's `paths.base` to the client and server SDKs so `tunnel: true` works under a base path without + * the user repeating it. It's imported first in the hooks files, so it's set before any `init` they import. + */ +export function makeTunnelBasePathPlugin(getKitConfig: () => Promise): Plugin { + let basePath = ''; + let entryFileRegexp: RegExp | undefined; + + return { + name: 'sentry-sveltekit-tunnel-base-path', + + configResolved: async () => { + const kitConfig = await getKitConfig(); + basePath = kitConfig.paths?.base || ''; + + const hooksFiles = [getHooksFileName(kitConfig, 'server'), getHooksFileName(kitConfig, 'client')] + .map(escapeStringForRegex) + .join('|'); + // oxlint-disable-next-line sdk/no-regexp-constructor -- not end user input + escaped anyway + entryFileRegexp = new RegExp(`/(?:${hooksFiles}|src/instrumentation\\.server)\\.(?:js|ts|mjs|mts)$`); + }, + + resolveId: id => (id === VIRTUAL_BASE_PATH_FILE ? id : null), + + load: id => + id === VIRTUAL_BASE_PATH_FILE + ? `globalThis[${JSON.stringify(TUNNEL_BASE_PATH_GLOBAL)}] = ${JSON.stringify(basePath)};` + : null, + + transform: (code, id) => { + if (!basePath || !entryFileRegexp?.test(id)) { + return null; + } + + return { code: `import ${JSON.stringify(VIRTUAL_BASE_PATH_FILE)};\n${code}`, map: null }; + }, + }; +} diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 651cc6af6f37..92e72386d69b 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -6,6 +6,7 @@ import { } from '@sentry/cloudflare'; import { addNonEnumerableProperty } from '@sentry/core'; import type { Handle } from '@sveltejs/kit'; +import { applyTunnelBasePath } from '../common/tunnel'; import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration'; import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans'; import { getCloudflareExecutionContext } from '../server-common/utils'; @@ -31,6 +32,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { ...options, }; + applyTunnelBasePath(opts); setAsyncLocalStorageAsyncContextStrategy(); const handleInitSentry: Handle = ({ event, resolve }) => { diff --git a/packages/sveltekit/test/common/tunnel.test.ts b/packages/sveltekit/test/common/tunnel.test.ts new file mode 100644 index 000000000000..83dc1cdcb4d4 --- /dev/null +++ b/packages/sveltekit/test/common/tunnel.test.ts @@ -0,0 +1,48 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyTunnelBasePath, TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_sveltekit_base_path; + }); + + it('adds the injected base path to the managed tunnel', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const fromTrue: Parameters[0] = { tunnel: true }; + const fromOptions: Parameters[0] = { tunnel: { allowedDsns: ['dsn'] } }; + + applyTunnelBasePath(fromTrue); + applyTunnelBasePath(fromOptions); + + expect(fromTrue.tunnel).toEqual({ basePath: '/app' }); + expect(fromOptions.tunnel).toEqual({ basePath: '/app', allowedDsns: ['dsn'] }); + }); + + it('keeps a base path the user set', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const options: Parameters[0] = { tunnel: { basePath: '/other' } }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/other' }); + }); + + it('leaves URL tunnels, a disabled tunnel, and apps without a base path alone', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const url: Parameters[0] = { tunnel: '/monitor' }; + const off: Parameters[0] = {}; + applyTunnelBasePath(url); + applyTunnelBasePath(off); + + delete globalWithBasePath.__sentry_sveltekit_base_path; + const noBase: Parameters[0] = { tunnel: true }; + applyTunnelBasePath(noBase); + + expect(url.tunnel).toBe('/monitor'); + expect(off.tunnel).toBeUndefined(); + expect(noBase.tunnel).toBe(true); + }); +}); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 8843e63ed1e6..3d081a2df4a0 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -141,6 +141,44 @@ describe('sentryHandle with span streaming', () => { }); }); +describe('sentryHandle with the managed tunnel', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + setCurrentClient(new NodeClient(getDefaultNodeClientOptions({ dsn: DSN, _managedTunnel: {} }))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('answers tunnel requests without resolving the route', async () => { + const tunnelPath = SentryCore.getTunnelPath(DSN)!; + const request = new Request(`http://localhost:3000${tunnelPath}`, { + method: 'POST', + body: SentryCore.serializeEnvelope(SentryCore.createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + const mockResolve = vi.fn(); + + const response = await sentryHandle()({ event: mockEvent({ request, route: { id: null } }), resolve: mockResolve }); + + expect(await response.text()).toBe('forwarded'); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it('resolves every other request', async () => { + const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); + + const response = await sentryHandle()({ event: mockEvent(), resolve: mockResolve }); + + expect(await response.text()).toBe('app'); + }); +}); + describe('sentryHandle', () => { describe.each([ // isSync, isError, expectedResponse diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index c78099729998..7cae5e1097c4 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -68,10 +68,10 @@ describe('sentrySvelteKit()', () => { expect(plugins).toBeInstanceOf(Array); // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver - // + 1 auto instrument plugin + // + 1 tunnel base path plugin + 1 auto instrument plugin // + 1 orchestrion plugin + 1 build flag plugin + 1 global values injection plugin + 1 modified main plugin // + 3 custom plugins - expect(plugins).toHaveLength(11); + expect(plugins).toHaveLength(12); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -84,6 +84,8 @@ describe('sentrySvelteKit()', () => { 'sentry-sveltekit-browser-tracing-variant', // OpenTelemetry API resolver: 'sentry-sveltekit-opentelemetry-api', + // tunnel base path plugin: + 'sentry-sveltekit-tunnel-base-path', // auto instrument plugin: 'sentry-auto-instrumentation', // orchestrion build-time instrumentation plugin: @@ -103,7 +105,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -111,9 +113,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[3]; + const instrumentPlugin = plugins[4]; - expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + global values injection + expect(plugins).toHaveLength(8); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -122,7 +124,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(10); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(11); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -234,7 +236,7 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[3]!; + const plugin = plugins[4]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ diff --git a/packages/sveltekit/test/vite/tunnelBasePath.test.ts b/packages/sveltekit/test/vite/tunnelBasePath.test.ts new file mode 100644 index 000000000000..1fd21f94c5cf --- /dev/null +++ b/packages/sveltekit/test/vite/tunnelBasePath.test.ts @@ -0,0 +1,44 @@ +import type { Plugin } from 'vite'; +import { describe, expect, it } from 'vitest'; +import type { ResolvedKitConfig } from '../../src/vite/kitConfig'; +import { makeTunnelBasePathPlugin } from '../../src/vite/tunnelBasePath'; + +type Hook = (...args: unknown[]) => unknown; + +async function setup(kitConfig: ResolvedKitConfig): Promise { + const plugin = makeTunnelBasePathPlugin(() => Promise.resolve(kitConfig)); + await (plugin.configResolved as Hook)({}); + + return plugin; +} + +describe('makeTunnelBasePathPlugin', () => { + it('imports the base path first in the hooks and instrumentation files', async () => { + const plugin = await setup({ paths: { base: '/app' } }); + const transform = plugin.transform as Hook; + + for (const id of ['/p/src/hooks.server.ts', '/p/src/hooks.client.js', '/p/src/instrumentation.server.ts']) { + expect(transform('const a = 1;', id)).toEqual({ + code: `import ${JSON.stringify('\0sentry-sveltekit-tunnel-base-path')};\nconst a = 1;`, + map: null, + }); + } + expect(transform('const a = 1;', '/p/src/routes/+page.svelte')).toBeNull(); + expect((plugin.load as Hook)('\0sentry-sveltekit-tunnel-base-path')).toBe( + 'globalThis["__sentry_sveltekit_base_path"] = "/app";', + ); + }); + + it('follows custom hooks file locations', async () => { + const plugin = await setup({ paths: { base: '/app' }, files: { hooks: { server: 'src/server/hooks' } } }); + + expect((plugin.transform as Hook)('', '/p/src/server/hooks.ts')).not.toBeNull(); + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); + + it('does nothing without a base path', async () => { + const plugin = await setup({}); + + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); +}); From 1d45cb75f0a8692b6231a6afd359cc7cea94713a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 22:18:58 -0400 Subject: [PATCH 46/93] fix(sveltekit): Keep source maps of the hooks files under a base path --- packages/sveltekit/src/vite/tunnelBasePath.ts | 6 +++++- packages/sveltekit/test/vite/tunnelBasePath.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/sveltekit/src/vite/tunnelBasePath.ts b/packages/sveltekit/src/vite/tunnelBasePath.ts index 0f8f81923ffe..c8197d8d74a2 100644 --- a/packages/sveltekit/src/vite/tunnelBasePath.ts +++ b/packages/sveltekit/src/vite/tunnelBasePath.ts @@ -1,4 +1,5 @@ import { escapeStringForRegex } from '@sentry/core'; +import MagicString from 'magic-string'; import type { Plugin } from 'vite'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; import type { ResolvedKitConfig } from './kitConfig'; @@ -40,7 +41,10 @@ export function makeTunnelBasePathPlugin(getKitConfig: () => Promise { for (const id of ['/p/src/hooks.server.ts', '/p/src/hooks.client.js', '/p/src/instrumentation.server.ts']) { expect(transform('const a = 1;', id)).toEqual({ code: `import ${JSON.stringify('\0sentry-sveltekit-tunnel-base-path')};\nconst a = 1;`, - map: null, + map: expect.objectContaining({ mappings: expect.any(String) }), }); } expect(transform('const a = 1;', '/p/src/routes/+page.svelte')).toBeNull(); From ab3ce59417f16f5baaeece96bbd8c054ed1a7a07 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:57:45 -0400 Subject: [PATCH 47/93] fix(sveltekit): Serve a relative `tunnel` path under the base path too --- packages/sveltekit/src/common/tunnel.ts | 8 ++------ packages/sveltekit/test/common/tunnel.test.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/sveltekit/src/common/tunnel.ts b/packages/sveltekit/src/common/tunnel.ts index f6d023c21b41..a7e6a0ff57e8 100644 --- a/packages/sveltekit/src/common/tunnel.ts +++ b/packages/sveltekit/src/common/tunnel.ts @@ -1,5 +1,5 @@ import type { Options } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; /** Set by `sentrySvelteKit()` to the app's `paths.base`. */ export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_sveltekit_base_path'; @@ -10,9 +10,5 @@ export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_sveltekit_base_path'; */ export function applyTunnelBasePath(options: Pick): void { const basePath = (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL]; - if (!basePath || !options.tunnel || typeof options.tunnel === 'string') { - return; - } - - options.tunnel = { basePath, ...(options.tunnel === true ? {} : options.tunnel) }; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); } diff --git a/packages/sveltekit/test/common/tunnel.test.ts b/packages/sveltekit/test/common/tunnel.test.ts index 83dc1cdcb4d4..0f1038c81733 100644 --- a/packages/sveltekit/test/common/tunnel.test.ts +++ b/packages/sveltekit/test/common/tunnel.test.ts @@ -30,9 +30,18 @@ describe('applyTunnelBasePath', () => { expect(options.tunnel).toEqual({ basePath: '/other' }); }); + it('serves a relative tunnel path under the base path too', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const options: Parameters[0] = { tunnel: '/monitor' }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app', path: '/monitor' }); + }); + it('leaves URL tunnels, a disabled tunnel, and apps without a base path alone', () => { globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; - const url: Parameters[0] = { tunnel: '/monitor' }; + const url: Parameters[0] = { tunnel: 'https://example.com/tunnel' }; const off: Parameters[0] = {}; applyTunnelBasePath(url); applyTunnelBasePath(off); @@ -41,7 +50,7 @@ describe('applyTunnelBasePath', () => { const noBase: Parameters[0] = { tunnel: true }; applyTunnelBasePath(noBase); - expect(url.tunnel).toBe('/monitor'); + expect(url.tunnel).toBe('https://example.com/tunnel'); expect(off.tunnel).toBeUndefined(); expect(noBase.tunnel).toBe(true); }); From 369952131e160c861ed807520505fb604bc1f2b4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:50:49 -0400 Subject: [PATCH 48/93] test(sveltekit): Check the managed tunnel under `vite dev` too --- .../e2e-tests/test-applications/sveltekit-3/package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 90a8de3cc012..b57949e2b20b 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -14,7 +14,8 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -40,6 +41,11 @@ "label": "sveltekit-3 (tunnel)", "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel" + }, + { + "label": "sveltekit-3 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" } ] } From 0b4cba0782bf1e3102a21f78d2d2fd16f2a274f0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:25:45 -0400 Subject: [PATCH 49/93] test(sveltekit): Check no span of a tunnel request is sent with Kit tracing on --- .../sveltekit-3/tests/tunnel.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts index 9da2f72ddb26..ea8d4e673c90 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts @@ -43,3 +43,31 @@ test('sends client events through the managed tunnel under the base path', async ]); expect(winner).toBe('anchor'); }); + +test('does not send spans of tunnel requests with Kit tracing on', async ({ page }) => { + // Kit's own `sveltekit.handle.root` span starts with only `http.url`, no `url.path`. + const tunnelSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + const url = span.attributes['http.url']?.value ?? span.attributes['url.path']?.value; + return typeof url === 'string' && TUNNEL_PATH.test(url.startsWith('http') ? new URL(url).pathname : url); + }); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/app/'); + await tunnelResponsePromise; + + // Anchor on a regular page span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + const url = span.attributes['http.url']?.value ?? span.attributes['url.path']?.value; + return typeof url === 'string' && url.endsWith('/app/users/456'); + }); + await page.request.get('/app/users/456'); + + const winner = await Promise.race([ + tunnelSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); From 7e96182cbccffead067915e94acb04e70a6e62b8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:02 -0400 Subject: [PATCH 50/93] ref(sveltekit): Import the tunnel handler from @sentry/core/server --- packages/sveltekit/src/server-common/handle.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 13bceea99cb0..1b38a0acc3a4 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -7,7 +7,6 @@ import { getDefaultIsolationScope, getIsolationScope, getTraceMetaTags, - handleTunnelRequestIfMatched, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -20,7 +19,7 @@ import { withIsolationScope, filterCollectedUrl, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; +import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; import { getTracePropagationData, sendErrorToSentry } from './utils'; From 7496f1d84cd2083c164cd93a96b0f7377dac9b91 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:59:06 -0400 Subject: [PATCH 51/93] feat(nuxt, nitro): Serve the managed tunnel before the app's server middleware On Nitro v3, Nuxt and `@sentry/nitro` put the tunnel check first in h3's middleware list, since Nitro runs the app's `server/middleware` before anything a module registers and has no public way to go first. On Nitro v2, Nuxt answers from the `request` hook. --- .../test-applications/nitro-3/instrument.mjs | 4 +- .../test-applications/nitro-3/package.json | 13 +++++- .../nitro-3/server/middleware/test.ts | 7 +++- .../test-applications/nitro-3/src/main.ts | 6 ++- .../nitro-3/tests/tunnel.test.ts | 40 +++++++++++++++++++ .../test-applications/nitro-3/vite.config.ts | 3 ++ .../test-applications/nuxt-4/nuxt.config.ts | 6 ++- .../test-applications/nuxt-4/package.json | 9 ++++- .../nuxt-4/sentry.client.config.ts | 2 +- .../nuxt-4/sentry.server.config.ts | 4 +- .../nuxt-4/server/middleware/03.auth.ts | 7 +++- .../nuxt-4/tests/tunnel.test.ts | 40 +++++++++++++++++++ .../test-applications/nuxt-5/nuxt.config.ts | 6 ++- .../test-applications/nuxt-5/package.json | 13 +++++- .../nuxt-5/sentry.client.config.ts | 2 +- .../nuxt-5/sentry.server.config.ts | 4 +- .../nuxt-5/server/middleware/03.auth.ts | 7 +++- .../nuxt-5/tests/tunnel.test.ts | 40 +++++++++++++++++++ packages/nitro/src/runtime/plugins/server.ts | 5 +++ .../runtime/plugins/tunnel-legacy.server.ts | 21 ++++++++++ .../nuxt/src/runtime/plugins/tunnel.server.ts | 12 ++++++ packages/nuxt/src/vite/nitroServer.ts | 2 + packages/nuxt/test/vite/nitroServer.test.ts | 30 ++++++++------ 23 files changed, 252 insertions(+), 31 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts create mode 100644 packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/plugins/tunnel.server.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs index 53b80d309a5b..ff6290e461a4 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/nitro'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.E2E_TEST_DSN, + tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index eb77a39f32fb..ae510682f00c 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -9,7 +9,9 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -25,5 +27,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nitro-3 (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts index 92d8f80c3756..0120ae6ce24e 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -1,6 +1,11 @@ -import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; +import { defineHandler, getQuery, HTTPError, setResponseHeader } from 'nitro/h3'; export default defineHandler(event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); const query = getQuery(event); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts index d27d0ba1763a..6c9d7e4d6e39 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -1,10 +1,12 @@ import * as Sentry from '@sentry/browser'; +declare const __E2E_TEST_TUNNEL__: boolean; + // Let's us test trace propagation Sentry.init({ environment: 'qa', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tunnel: 'http://localhost:3031/', // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..0ae8c2181aa3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/api/anything', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nitro-3', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nitro-3', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-transaction', + ); + await page.request.get('/api/test-transaction'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts index d488f8298777..3fa4ce57a69c 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -3,6 +3,9 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ nitro( // FIXME: Nitro plugin has a type issue diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index c7acd2b12328..f8206a22f2f7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, @@ -16,8 +18,10 @@ export default defineNuxtConfig({ runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 5f84fb085b6d..c8a7f018868d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -16,7 +16,9 @@ "test:build": "pnpm install && node ./scripts/build-with-prerender-event-sink.mjs", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && node ./scripts/build-with-prerender-event-sink.mjs", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:prod:import": "TEST_ENV=production-import playwright test" + "test:prod:import": "TEST_ENV=production-import playwright test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 node ./scripts/build-with-prerender-event-sink.mjs", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -45,6 +47,11 @@ "build-command": "pnpm test:build", "assert-command": "pnpm test:prod:import", "label": "nuxt-4 (--import compat)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4 (tunnel)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 7b5d97ff0b09..64c786d5f7e8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -3,7 +3,7 @@ import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index acce04cc9d04..1e29b95f161a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts index 6dcd9a075589..471d1d32d7a4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts @@ -1,6 +1,11 @@ -import { defineEventHandler, setHeader, getQuery } from '#imports'; +import { createError, defineEventHandler, getHeader, setHeader, getQuery } from '#imports'; export default defineEventHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.method === 'POST' && getHeader(event, 'x-auth') !== 'ok') { + throw createError({ statusCode: 401, statusMessage: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts new file mode 100644 index 000000000000..aeccb68e67f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/api/anything', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nuxt-4', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-4', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-param/123', + ); + await page.request.get('/api/test-param/123'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index bdef334cfa88..63557fb9a85b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', imports: { autoImport: false }, @@ -16,8 +18,10 @@ export default defineNuxtConfig({ runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index 6e84b33b97fa..f6982d173489 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -15,7 +15,9 @@ "test:dev": "TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -38,7 +40,14 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-5 (tunnel)" + } + ] }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts index 900df0811faa..f5a30830107b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -3,7 +3,7 @@ import { /* usePinia,*/ useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ /* Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts index 58d541b5228b..ae05c60355bb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server // Several tests trigger the same server error one after another, which Dedupe would drop. integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts index 7216e9fc7560..62d2eebd07e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -1,7 +1,12 @@ import { defineHandler } from 'nitro'; -import { getQuery } from 'nitro/h3'; +import { getQuery, HTTPError } from 'nitro/h3'; export default defineHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts new file mode 100644 index 000000000000..5dec3575dd0c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/api/anything', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nuxt-5', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-5', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-param/123', + ); + await page.request.get('/api/test-param/123'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 9fd3f93a6f40..982877868d6b 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,3 +1,4 @@ +import { handleTunnelRequestIfMatched } from '@sentry/core'; import { definePlugin } from 'nitro'; import { captureErrorHook } from '../hooks/captureErrorHook'; import { captureStorageEvents } from '../hooks/captureStorageEvents'; @@ -6,6 +7,10 @@ import { captureTracingEvents } from '../hooks/captureTracingEvents'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); + // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block + // the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through this array. + nitroApp.h3?.['~middleware'].unshift((event, next) => handleTunnelRequestIfMatched(event.req) ?? next()); + captureTracingEvents(); captureStorageEvents(); }); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts new file mode 100644 index 000000000000..f6c06bc20d43 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -0,0 +1,21 @@ +import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +import { sendWebResponse, toWebRequest } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; + +/** + * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2). The `request` hook runs before route rules and + * the app's `server/middleware`. + */ +export default (nitroApp => { + nitroApp.hooks.hook('request', async event => { + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest({ method: event.method, url: event.path })) { + return; + } + + const tunnelResponse = handleTunnelRequestIfMatched(toWebRequest(event)); + if (tunnelResponse) { + await sendWebResponse(event, await tunnelResponse); + } + }); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts new file mode 100644 index 000000000000..5072b3023fab --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -0,0 +1,12 @@ +import { handleTunnelRequestIfMatched } from '@sentry/core'; +import type { NitroAppPlugin, NitroApp } from 'nitro/types'; + +/** + * Answers managed tunnel requests on Nuxt v5+ (Nitro v3+). + */ +export default ((nitroApp: NitroApp) => { + // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would + // block the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through + // this same array. + nitroApp.h3?.['~middleware'].unshift((event, next) => handleTunnelRequestIfMatched(event.req) ?? next()); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 500e94f1f8f6..eebfd8e12ccf 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -50,9 +50,11 @@ export function setupNitroServer( if (isNitroV3) { addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/tunnel.server')); } else { addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/tunnel-legacy.server')); } addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index a1ab7e7d40e2..44649c1c536f 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -65,26 +65,32 @@ describe('setupNitroServer', () => { serverApi: 'nitro2', handler: 'handler-legacy.server', updateRouteName: 'update-route-name-legacy.server', + tunnel: 'tunnel-legacy.server', storage: 'storage-legacy.server', }, { serverApi: 'nitro3', handler: 'handler.server', updateRouteName: 'update-route-name.server', + tunnel: 'tunnel.server', storage: 'storage.server', }, - ] as const)('registers the $handler plugins on $serverApi', ({ serverApi, handler, updateRouteName, storage }) => { - const { nuxt } = createFakeNuxt(); - - setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); - - expect(addNitroPluginMock).toHaveBeenCalledTimes(5); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, './runtime/plugins/sentry.server'); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, `./runtime/plugins/${storage}`); - }); + ] as const)( + 'registers the $handler plugins on $serverApi', + ({ serverApi, handler, updateRouteName, tunnel, storage }) => { + const { nuxt } = createFakeNuxt(); + + setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); + + expect(addNitroPluginMock).toHaveBeenCalledTimes(6); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, `./runtime/plugins/${tunnel}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, './runtime/plugins/sentry.server'); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(6, `./runtime/plugins/${storage}`); + }, + ); it('instruments the middleware and adds the config shim once Nitro initializes', () => { const { nuxt, callHook } = createFakeNuxt(); From 588d663c7128c63a37d1c64088f718b1d43c3ea3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:08:25 -0400 Subject: [PATCH 52/93] feat(nuxt, nitro): Serve the managed tunnel under the app's base URL --- .../test-applications/nitro-3/package.json | 9 ++++- .../test-applications/nitro-3/src/main.ts | 8 +++- .../nitro-3/tests/tunnel.test.ts | 12 +++--- .../test-applications/nitro-3/vite.config.ts | 2 + .../test-applications/nuxt-4/nuxt.config.ts | 3 ++ .../test-applications/nuxt-4/package.json | 9 ++++- .../nuxt-4/tests/tunnel.test.ts | 12 +++--- .../test-applications/nuxt-5/nuxt.config.ts | 3 ++ .../test-applications/nuxt-5/package.json | 9 ++++- .../nuxt-5/tests/tunnel.test.ts | 12 +++--- packages/nitro/src/runtime/plugins/server.ts | 14 ++++++- packages/nuxt/src/client/sdk.ts | 2 + packages/nuxt/src/common/tunnel.ts | 17 ++++++++ packages/nuxt/src/module.ts | 4 +- .../runtime/plugins/tunnel-legacy.server.ts | 11 ++++- .../nuxt/src/runtime/plugins/tunnel.server.ts | 14 ++++++- packages/nuxt/test/common/tunnel.test.ts | 40 +++++++++++++++++++ 17 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 packages/nuxt/src/common/tunnel.ts create mode 100644 packages/nuxt/test/common/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index ae510682f00c..de6dc0485c60 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel" + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -34,6 +36,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "nitro-3 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nitro-3 (tunnel-basepath)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts index 6c9d7e4d6e39..d09a43e411ed 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -1,12 +1,18 @@ import * as Sentry from '@sentry/browser'; declare const __E2E_TEST_TUNNEL__: boolean; +declare const __E2E_TEST_BASE_PATH__: string; // Let's us test trace propagation Sentry.init({ environment: 'qa', dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', - tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server + // Plain Nitro has no client SDK to pass `baseURL` on, so the browser names it. The server picks it up itself. + tunnel: __E2E_TEST_TUNNEL__ + ? __E2E_TEST_BASE_PATH__ + ? { basePath: __E2E_TEST_BASE_PATH__ } + : true + : 'http://localhost:3031/', // proxy server integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts index 0ae8c2181aa3..f32d0563ff3a 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -3,11 +3,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); test('answers the managed tunnel before the app auth middleware', async ({ page }) => { // The variant's auth middleware rejects every other POST. - expect((await page.request.post('/api/anything', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( 'nitro-3', @@ -17,7 +18,7 @@ test('answers the managed tunnel before the app auth middleware', async ({ page response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app auth middleware', async ({ page const anchorSpanPromise = waitForStreamedSpan( 'nitro-3', span => - getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-transaction', + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-transaction`, ); - await page.request.get('/api/test-transaction'); + await page.request.get(`${BASE_PATH}/api/test-transaction`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts index 3fa4ce57a69c..b7c6de66d47c 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ define: { __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + __E2E_TEST_BASE_PATH__: JSON.stringify(process.env.E2E_TEST_BASE_PATH ?? ''), }, plugins: [ nitro( @@ -12,6 +13,7 @@ export default defineConfig({ // @ts-expect-error withSentryConfig({ serverDir: './server', + ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), }), ), ], diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index f8206a22f2f7..b137ec2b0e52 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -14,6 +14,9 @@ export default defineNuxtConfig({ '/rendering-modes/pre-rendered-page': { prerender: true }, }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index c8a7f018868d..ae2f34588c1f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -18,7 +18,9 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:prod:import": "TEST_ENV=production-import playwright test", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 node ./scripts/build-with-prerender-event-sink.mjs", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel" + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -52,6 +54,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "nuxt-4 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-4 (tunnel-basepath)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts index aeccb68e67f5..1e48be35166c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -3,11 +3,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); test('answers the managed tunnel before the app auth middleware', async ({ page }) => { // The variant's auth middleware rejects every other POST. - expect((await page.request.post('/api/anything', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( 'nuxt-4', @@ -17,7 +18,7 @@ test('answers the managed tunnel before the app auth middleware', async ({ page response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app auth middleware', async ({ page const anchorSpanPromise = waitForStreamedSpan( 'nuxt-4', span => - getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-param/123', + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, ); - await page.request.get('/api/test-param/123'); + await page.request.get(`${BASE_PATH}/api/test-param/123`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index 63557fb9a85b..8016834d1167 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -14,6 +14,9 @@ export default defineNuxtConfig({ '/rendering-modes/pre-rendered-page': { prerender: true }, }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index f6982d173489..c3d5f9586eb2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -17,7 +17,9 @@ "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel" + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -46,6 +48,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "nuxt-5 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-5 (tunnel-basepath)" } ] }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts index 5dec3575dd0c..d814f3fab1c9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts @@ -3,11 +3,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); test('answers the managed tunnel before the app auth middleware', async ({ page }) => { // The variant's auth middleware rejects every other POST. - expect((await page.request.post('/api/anything', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( 'nuxt-5', @@ -17,7 +18,7 @@ test('answers the managed tunnel before the app auth middleware', async ({ page response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app auth middleware', async ({ page const anchorSpanPromise = waitForStreamedSpan( 'nuxt-5', span => - getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-param/123', + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, ); - await page.request.get('/api/test-param/123'); + await page.request.get(`${BASE_PATH}/api/test-param/123`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 982877868d6b..e28d8ae9282a 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,5 +1,6 @@ -import { handleTunnelRequestIfMatched } from '@sentry/core'; +import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; import { definePlugin } from 'nitro'; +import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; import { captureStorageEvents } from '../hooks/captureStorageEvents'; import { captureTracingEvents } from '../hooks/captureTracingEvents'; @@ -9,7 +10,16 @@ export default definePlugin(nitroApp => { // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block // the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through this array. - nitroApp.h3?.['~middleware'].unshift((event, next) => handleTunnelRequestIfMatched(event.req) ?? next()); + // Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. + const applyBaseURL = (): void => + setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); + applyBaseURL(); + nitroApp.h3?.['~middleware'].unshift((event, next) => { + // `init` may run after this plugin, so this also covers the first requests after it. + applyBaseURL(); + + return handleTunnelRequestIfMatched(event.req) ?? next(); + }); captureTracingEvents(); captureStorageEvents(); diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index 76428543a716..4aa7891645b8 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,6 +1,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowser } from '@sentry/browser'; import type { Client } from '@sentry/core'; import { applySdkMetadata, DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from '@sentry/core'; +import { applyNuxtTunnelBasePath } from '../common/tunnel'; import type { SentryNuxtClientOptions } from '../common/types'; /** @@ -17,6 +18,7 @@ export function init(options: SentryNuxtClientOptions): Client | undefined { ...options, }; + applyNuxtTunnelBasePath(sentryOptions); applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'vue']); return initBrowser(sentryOptions); diff --git a/packages/nuxt/src/common/tunnel.ts b/packages/nuxt/src/common/tunnel.ts new file mode 100644 index 000000000000..9b5423f06d66 --- /dev/null +++ b/packages/nuxt/src/common/tunnel.ts @@ -0,0 +1,17 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; + +/** Set by the generated client config plugin to the app's runtime `app.baseURL`. */ +export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; + +/** + * Serves the managed tunnel under the app's `app.baseURL`, which a proxy in front of the app may be the only path + * forwarded to it. It's read from runtime config, so a `NUXT_APP_BASE_URL` set at runtime is honoured too. + * A `basePath` the user set wins. + */ +export function applyNuxtTunnelBasePath(options: Pick): void { + const baseURL = (GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string })[NUXT_TUNNEL_BASE_PATH_GLOBAL]; + // A relative `./` base means the app is served from wherever it's deployed, which Nitro treats as the root. + const basePath = baseURL?.replace(/^\.\//, '/').replace(/\/+$/, ''); + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 10f22891ac2d..643fc3bcd2eb 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -3,6 +3,7 @@ import { addPlugin, addPluginTemplate, addTemplate, addVitePlugin, createResolve import type {} from '@nuxt/nitro-server'; import * as path from 'path'; +import { NUXT_TUNNEL_BASE_PATH_GLOBAL } from './common/tunnel'; import type { SentryNuxtModuleOptions } from './common/types'; import { setupNitroServer } from './vite/nitroServer'; import { setupOrchestrion } from './vite/orchestrion'; @@ -55,11 +56,12 @@ export default defineNuxtModule({ // Dynamic import of config file to wrap it within a Nuxt context (here: defineNuxtPlugin) // Makes it possible to call useRuntimeConfig() in the user-defined sentry config file getContents: () => ` - import { defineNuxtPlugin } from "#imports"; + import { defineNuxtPlugin, useRuntimeConfig } from "#imports"; export default defineNuxtPlugin({ name: 'sentry-client-config', async setup() { + globalThis.${NUXT_TUNNEL_BASE_PATH_GLOBAL} = useRuntimeConfig().app.baseURL; await import("${buildDirResolver.resolve(`/${clientConfigFile}`)}") } });`, diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index f6c06bc20d43..4a3b282bbc64 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,13 +1,22 @@ -import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +import { getClient, handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelBasePath } from '@sentry/core'; import { sendWebResponse, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; +import { useRuntimeConfig } from 'nitropack/runtime'; /** * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2). The `request` hook runs before route rules and * the app's `server/middleware`. */ export default (nitroApp => { + // The `request` hook sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. + // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. + const applyBaseURL = (): void => setManagedTunnelBasePath(getClient(), useRuntimeConfig().app?.baseURL); + applyBaseURL(); + nitroApp.hooks.hook('request', async event => { + // `init` may run after this plugin, so this also covers the first requests after it. + applyBaseURL(); + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. if (!isTunnelRequest({ method: event.method, url: event.path })) { return; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 5072b3023fab..8ebf122e5ad2 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,4 +1,5 @@ -import { handleTunnelRequestIfMatched } from '@sentry/core'; +import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { useRuntimeConfig } from 'nitro/runtime-config'; import type { NitroAppPlugin, NitroApp } from 'nitro/types'; /** @@ -8,5 +9,14 @@ export default ((nitroApp: NitroApp) => { // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would // block the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through // this same array. - nitroApp.h3?.['~middleware'].unshift((event, next) => handleTunnelRequestIfMatched(event.req) ?? next()); + // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. + const applyBaseURL = (): void => + setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); + applyBaseURL(); + nitroApp.h3?.['~middleware'].unshift((event, next) => { + // `init` may run after this plugin, so this also covers the first requests after it. + applyBaseURL(); + + return handleTunnelRequestIfMatched(event.req) ?? next(); + }); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/test/common/tunnel.test.ts b/packages/nuxt/test/common/tunnel.test.ts new file mode 100644 index 000000000000..ed491b077fdd --- /dev/null +++ b/packages/nuxt/test/common/tunnel.test.ts @@ -0,0 +1,40 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyNuxtTunnelBasePath, NUXT_TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyNuxtTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_nuxt_base_path; + }); + + it('serves the managed tunnel under `app.baseURL`', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/app/'; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('keeps a base path the user set', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/app/'; + const options: Parameters[0] = { tunnel: { basePath: '/custom' } }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/custom' }); + }); + + it('serves from the root for the default and relative base URLs', () => { + for (const baseURL of ['/', './']) { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = baseURL; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + } + }); +}); From 694b885fade160e5b11fa235a47f6efa4c8c8c0d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:46:09 -0400 Subject: [PATCH 53/93] fix(nitro): Drop the tunnel's dev server spans under `baseURL` too --- .../test-applications/nitro-3/package.json | 14 ++++++- .../nitro-3/playwright.config.mjs | 5 ++- .../test-applications/nuxt-4/package.json | 14 ++++++- .../test-applications/nuxt-5/package.json | 8 +++- packages/nitro/src/module.ts | 7 ++++ packages/nitro/test/module.test.ts | 42 +++++++++++++++++++ 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 packages/nitro/test/module.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index de6dc0485c60..9df73a3af556 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -13,7 +13,9 @@ "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel", - "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", + "test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 playwright test tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -41,6 +43,16 @@ "build-command": "pnpm test:build:tunnel-basepath", "assert-command": "pnpm test:assert:tunnel-basepath", "label": "nitro-3 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nitro-3 (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nitro-3 (tunnel-basepath-dev)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs index 31f2b913b58b..62bc93244137 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs @@ -1,7 +1,10 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: + process.env.TEST_ENV === 'development' + ? `PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' pnpm exec vite dev --port 3030` + : `pnpm start`, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index ae2f34588c1f..6a5c249f7aef 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -20,7 +20,9 @@ "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 node ./scripts/build-with-prerender-event-sink.mjs", "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs", "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", - "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel" + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=development playwright test tunnel" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -59,6 +61,16 @@ "build-command": "pnpm test:build:tunnel-basepath", "assert-command": "pnpm test:assert:tunnel-basepath", "label": "nuxt-4 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-4 (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nuxt-4 (tunnel-basepath-dev)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index c3d5f9586eb2..1b04f519e281 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -19,7 +19,8 @@ "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", - "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel" + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -53,6 +54,11 @@ "build-command": "pnpm test:build:tunnel-basepath", "assert-command": "pnpm test:assert:tunnel-basepath", "label": "nuxt-5 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-5 (tunnel-dev)" } ] }, diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 1a4e5b0478d1..8df28a8a2550 100644 --- a/packages/nitro/src/module.ts +++ b/packages/nitro/src/module.ts @@ -1,3 +1,4 @@ +import { getClient, setManagedTunnelBasePath } from '@sentry/core'; import type { NitroModule } from 'nitro/types'; import type { SentryNitroOptions } from './config'; import { instrumentServer } from './instruments/instrumentServer'; @@ -12,6 +13,12 @@ export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnab setup: nitro => { instrumentServer(nitro); setupSourceMaps(nitro, sentryOptions, sentryEnabledSourcemaps); + + // In dev, Vite's server takes each request in this process before Nitro handles it, and its `http.server` span + // comes from the SDK `--import` set up here. The runtime plugin only reaches the client where Nitro runs. + if (nitro.options.dev) { + setManagedTunnelBasePath(getClient(), nitro.options.baseURL); + } }, }; } diff --git a/packages/nitro/test/module.test.ts b/packages/nitro/test/module.test.ts new file mode 100644 index 000000000000..f967e66da9bb --- /dev/null +++ b/packages/nitro/test/module.test.ts @@ -0,0 +1,42 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, makeDsn } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createNitroModule } from '../src/module'; + +vi.mock('../src/instruments/instrumentServer', () => ({ instrumentServer: vi.fn() })); +vi.mock('../src/sourceMaps', () => ({ setupSourceMaps: vi.fn() })); + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +function setClient(managedTunnel: object | undefined): Record { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + + return options; +} + +function setup(options: { dev: boolean; baseURL: string }): void { + (createNitroModule().setup as (nitro: unknown) => void)({ options }); +} + +describe('createNitroModule', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it("serves the dev server's managed tunnel under `baseURL`", () => { + const options = setClient({}); + + setup({ dev: true, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({ basePath: '/app' }); + }); + + it('leaves the client alone in a build, where the runtime plugin applies the base path', () => { + const options = setClient({}); + + setup({ dev: false, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({}); + }); +}); From ea8b71798863fe6837eb2542b3ea6d5bed9db618 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:00:43 -0400 Subject: [PATCH 54/93] fix(nuxt): Normalize the tunnel base path with the core util --- packages/nuxt/src/common/tunnel.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/nuxt/src/common/tunnel.ts b/packages/nuxt/src/common/tunnel.ts index 9b5423f06d66..58b23d06b854 100644 --- a/packages/nuxt/src/common/tunnel.ts +++ b/packages/nuxt/src/common/tunnel.ts @@ -1,5 +1,5 @@ import type { Options } from '@sentry/core'; -import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; /** Set by the generated client config plugin to the app's runtime `app.baseURL`. */ export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; @@ -11,7 +11,6 @@ export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; */ export function applyNuxtTunnelBasePath(options: Pick): void { const baseURL = (GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string })[NUXT_TUNNEL_BASE_PATH_GLOBAL]; - // A relative `./` base means the app is served from wherever it's deployed, which Nitro treats as the root. - const basePath = baseURL?.replace(/^\.\//, '/').replace(/\/+$/, ''); + const basePath = normalizeTunnelBasePath(baseURL); options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); } From 1cc04f9b36afea9ab834427be23abda6ae0a9622 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:08:30 -0400 Subject: [PATCH 55/93] fix(nuxt, nitro): Serve the managed tunnel from `nitroApp.fetch` so route rules can't skip it --- .../test-applications/nitro-3/package.json | 9 ++++++++- .../test-applications/nitro-3/tests/tunnel.test.ts | 13 +++++++++++++ .../test-applications/nitro-3/vite.config.ts | 7 +++++++ packages/nitro/src/runtime/plugins/server.ts | 11 ++++++----- packages/nuxt/src/runtime/plugins/tunnel.server.ts | 11 ++++++----- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index 9df73a3af556..cfc11a05b65e 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -15,7 +15,9 @@ "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel", "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", "test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 playwright test tunnel", - "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", + "test:build:tunnel-no-middleware": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 pnpm build", + "test:assert:tunnel-no-middleware": "E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 playwright test tunnel -g 'without app middleware'" }, "dependencies": { "@sentry/browser": "latest || *", @@ -44,6 +46,11 @@ "assert-command": "pnpm test:assert:tunnel-basepath", "label": "nitro-3 (tunnel-basepath)" }, + { + "build-command": "pnpm test:build:tunnel-no-middleware", + "assert-command": "pnpm test:assert:tunnel-no-middleware", + "label": "nitro-3 (tunnel-no-middleware)" + }, { "build-command": "pnpm install", "assert-command": "pnpm test:assert:tunnel-dev", diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts index f32d0563ff3a..3782c67a86a0 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/core'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -40,3 +41,15 @@ test('answers the managed tunnel before the app auth middleware', async ({ page ]); expect(winner).toBe('anchor'); }); + +test('answers the managed tunnel without app middleware', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const envelopeHeader = { dsn, sent_at: new Date().toISOString() }; + const response = await request.post(`${BASE_PATH}${getTunnelPath(dsn)}`, { + data: `${JSON.stringify(envelopeHeader)}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts index b7c6de66d47c..083005ad7ac4 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -14,6 +14,13 @@ export default defineConfig({ withSentryConfig({ serverDir: './server', ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), + // Without static assets, global middleware or routed middleware, Nitro skips its middleware array whenever + // route rules exist, as on serverless presets. + ...(process.env.E2E_TEST_NO_MIDDLEWARE && { + serveStatic: false, + ignore: ['**/middleware/**'], + routeRules: { '/api/**': { headers: { 'x-e2e-route-rule': '1' } } }, + }), }), ), ], diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index e28d8ae9282a..b9e739f10297 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -9,17 +9,18 @@ export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block - // the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through this array. - // Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. + // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, + // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. const applyBaseURL = (): void => setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); applyBaseURL(); - nitroApp.h3?.['~middleware'].unshift((event, next) => { + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => { // `init` may run after this plugin, so this also covers the first requests after it. applyBaseURL(); - return handleTunnelRequestIfMatched(event.req) ?? next(); - }); + return handleTunnelRequestIfMatched(request) ?? appFetch(request); + }; captureTracingEvents(); captureStorageEvents(); diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 8ebf122e5ad2..8f57371e9eb6 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -7,16 +7,17 @@ import type { NitroAppPlugin, NitroApp } from 'nitro/types'; */ export default ((nitroApp: NitroApp) => { // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would - // block the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through - // this same array. + // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes + // first, before route rules and middleware. // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. const applyBaseURL = (): void => setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); applyBaseURL(); - nitroApp.h3?.['~middleware'].unshift((event, next) => { + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => { // `init` may run after this plugin, so this also covers the first requests after it. applyBaseURL(); - return handleTunnelRequestIfMatched(event.req) ?? next(); - }); + return handleTunnelRequestIfMatched(request) ?? appFetch(request); + }; }) satisfies NitroAppPlugin; From bd02881a0c54c986f3d239255756dc3b59ba3b17 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:00:51 -0400 Subject: [PATCH 56/93] fix(nuxt): Serve the Nitro v2 tunnel from the first h3 layer so route rules don't run after it --- .../test-applications/nuxt-4/nuxt.config.ts | 2 ++ .../nuxt-4/tests/tunnel.test.ts | 32 +++++++++++++++++- .../runtime/plugins/tunnel-legacy.server.ts | 33 ++++++++++--------- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index b137ec2b0e52..4f456ce2efdf 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -12,6 +12,8 @@ export default defineNuxtConfig({ '/rendering-modes/swr-cached-page': { swr: true }, '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, '/rendering-modes/pre-rendered-page': { prerender: true }, + // A headers rule on every path, like a security headers setup, also covers the tunnel's path. + ...(tunnelVariant && { '/**': { headers: { 'x-e2e-route-rule': '1' } } }), }, // The base-path variant serves the app, and so the tunnel, under `/app`. diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts index 1e48be35166c..20fa9eed14a9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -40,3 +40,33 @@ test('answers the managed tunnel before the app auth middleware', async ({ page ]); expect(winner).toBe('anchor'); }); + +test('answers the managed tunnel once, without errors from route rules that run after it', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const tunnelPath = '/qpo967'; + const tunnelErrorPromise = waitForError( + 'nuxt-4', + event => !!event.exception?.values?.[0]?.value?.includes('Cannot set headers'), + ); + const anchorErrorPromise = waitForError( + 'nuxt-4', + event => event.exception?.values?.[0]?.value === 'Nuxt 4 Server error', + ); + + const response = await request.post(`${BASE_PATH}${tunnelPath}`, { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); + + // Errors are sent in order, so one from the tunnel request would arrive before this later one. + await request.get(`${BASE_PATH}/api/server-error`); + + const winner = await Promise.race([ + tunnelErrorPromise.then(() => 'tunnel' as const), + anchorErrorPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index 4a3b282bbc64..648eda88c08a 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,30 +1,33 @@ import { getClient, handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelBasePath } from '@sentry/core'; -import { sendWebResponse, toWebRequest } from 'h3'; +import { eventHandler, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import { useRuntimeConfig } from 'nitropack/runtime'; /** - * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2). The `request` hook runs before route rules and - * the app's `server/middleware`. + * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2), from the first layer of the h3 app. That runs + * before route rules and the app's `server/middleware`. */ export default (nitroApp => { - // The `request` hook sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. + // The first layer sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. const applyBaseURL = (): void => setManagedTunnelBasePath(getClient(), useRuntimeConfig().app?.baseURL); applyBaseURL(); - nitroApp.hooks.hook('request', async event => { - // `init` may run after this plugin, so this also covers the first requests after it. - applyBaseURL(); + // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule + // that layer throws for writing headers on the sent response. + nitroApp.h3App.stack.unshift({ + route: '', + handler: eventHandler(event => { + // `init` may run after this plugin, so this also covers the first requests after it. + applyBaseURL(); - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isTunnelRequest({ method: event.method, url: event.path })) { - return; - } + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest({ method: event.method, url: event.path })) { + return undefined; + } - const tunnelResponse = handleTunnelRequestIfMatched(toWebRequest(event)); - if (tunnelResponse) { - await sendWebResponse(event, await tunnelResponse); - } + // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. + return handleTunnelRequestIfMatched(toWebRequest(event)); + }), }); }) satisfies NitroAppPlugin; From 62d62fd004f539773825e0822667e9d321dcd0e1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:29:15 -0400 Subject: [PATCH 57/93] test(nitro): Check a client dropping the tunnel request isn't reported as an error --- .../nitro-3/tests/tunnel.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts index 3782c67a86a0..2735ea4a7193 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { getTunnelPath } from '@sentry/core'; +import { connect } from 'node:net'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -53,3 +54,30 @@ test('answers the managed tunnel without app middleware', async ({ request }) => expect(response.headers()['x-sentry-tunnel']).toBe('1'); expect(response.status()).toBe(200); }); + +test('does not report a client that drops the connection before the envelope header as an error', async ({ + request, +}) => { + const dsn = 'http://public@localhost:3031/1337'; + const firstErrorPromise = waitForError('nitro-3', () => true); + + // Part of the envelope header line, without its newline, then the connection drops. + await new Promise(resolve => { + const socket = connect(3030, 'localhost', () => { + socket.write( + `POST ${BASE_PATH}${getTunnelPath(dsn)} HTTP/1.1\r\nHost: localhost:3030\r\nContent-Type: application/x-sentry-envelope\r\nContent-Length: 1000000\r\n\r\n`, + ); + socket.write(JSON.stringify({ dsn, padding: 'x'.repeat(1000) }).slice(0, 500)); + setTimeout(() => { + socket.destroy(); + resolve(); + }, 300); + }); + }); + + // Errors are sent in order, so one from the dropped request would arrive before this one. + await request.get(`${BASE_PATH}/api/test-error`); + + const firstError = await firstErrorPromise; + expect(firstError.exception?.values?.[0]?.value).toBe('This is a test error'); +}); From b18e464a49cca4c12ea7ff8ac676afe824a3ea2a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:28:39 -0400 Subject: [PATCH 58/93] fix(nuxt): Serve the managed tunnel when the SDK is set up with sentryCloudflareNitroPlugin --- .../nuxt-4-cloudflare/nuxt.config.ts | 4 ++++ .../nuxt-4-cloudflare/package.json | 13 ++++++++++++- .../nuxt-4-cloudflare/server/plugins/sentry.ts | 7 +++++-- .../nuxt-4-cloudflare/tests/tunnel.test.ts | 15 +++++++++++++++ packages/nuxt/src/module.ts | 5 ++++- packages/nuxt/src/vite/nitroServer.ts | 17 +++++++++++++++-- 6 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts index 63f6e2e2633b..27cc219f447f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -14,5 +14,9 @@ export default defineNuxtConfig({ alias: { 'readable-stream': 'node:stream', }, + // The tunnel variant serves the managed tunnel. + replace: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json index 9947f20ef982..76dbfd4b6149 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -8,7 +8,9 @@ "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test tunnel" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -23,5 +25,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4-cloudflare (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts index faa56e1db7ef..794c67678b48 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts @@ -1,10 +1,13 @@ import { defineNitroPlugin } from '#imports'; import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'; +declare const __E2E_TEST_TUNNEL__: boolean; + export default defineNitroPlugin( sentryCloudflareNitroPlugin({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel forwards to the DSN host, which is the event proxy in the tunnel variant. + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server }), ); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..b239562b2194 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +test('answers the managed tunnel when the SDK is set up with `sentryCloudflareNitroPlugin`', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const response = await request.post('/qpo967', { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 643fc3bcd2eb..5d23d675f3fc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -5,7 +5,7 @@ import type {} from '@nuxt/nitro-server'; import * as path from 'path'; import { NUXT_TUNNEL_BASE_PATH_GLOBAL } from './common/tunnel'; import type { SentryNuxtModuleOptions } from './common/types'; -import { setupNitroServer } from './vite/nitroServer'; +import { addTunnelPlugin, setupNitroServer } from './vite/nitroServer'; import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { findDefaultSdkInitFile, resolveServerApi } from './vite/utils'; @@ -93,6 +93,9 @@ export default defineNuxtModule({ } else { addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); } + } else { + // The Cloudflare setup initializes the SDK from `sentryCloudflareNitroPlugin`, so it needs the tunnel too. + addTunnelPlugin(serverApi); } if (clientConfigFile || serverConfigFile) { diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index eebfd8e12ccf..270576ab49d7 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -50,13 +50,13 @@ export function setupNitroServer( if (isNitroV3) { addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); - addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/tunnel.server')); } else { addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); - addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/tunnel-legacy.server')); } + addTunnelPlugin(serverApi); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); // Preps the middleware instrumentation module. @@ -125,3 +125,16 @@ function setupDeprecatedInjectMode( } } } + +/** + * Adds the plugin that answers the managed tunnel. The Cloudflare setup has no server config file, so it skips + * `setupNitroServer` and adds this on its own. + */ +export function addTunnelPlugin(serverApi: ServerApi): void { + const moduleDirResolver = createResolver(import.meta.url); + addNitroPlugin( + moduleDirResolver.resolve( + serverApi === 'nitro3' ? './runtime/plugins/tunnel.server' : './runtime/plugins/tunnel-legacy.server', + ), + ); +} From fb58f66050fc243fc0e408a99c6c99edad8f1b0f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:09 -0400 Subject: [PATCH 59/93] ref(nuxt, nitro): Report the base URL once with setManagedTunnelDefaults so Cloudflare clients start with it --- .../nuxt-4-cloudflare/nuxt.config.ts | 2 ++ .../nuxt-4-cloudflare/server/api/anchor.ts | 4 +++ .../nuxt-4-cloudflare/tests/tunnel.test.ts | 30 ++++++++++++++++++- packages/nitro/src/module.ts | 4 +-- packages/nitro/src/runtime/plugins/server.ts | 13 ++------ .../runtime/plugins/tunnel-legacy.server.ts | 8 ++--- .../nuxt/src/runtime/plugins/tunnel.server.ts | 16 ++++------ 7 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts index 27cc219f447f..b83483ac0363 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -2,6 +2,8 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-06', modules: ['@sentry/nuxt/module'], + // The tunnel variant serves the app, and so the tunnel, under a base URL. + ...(process.env.E2E_TEST_TUNNEL && { app: { baseURL: '/app/' } }), nitro: { preset: 'cloudflare_module', cloudflare: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts new file mode 100644 index 000000000000..cee7601e363b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts @@ -0,0 +1,4 @@ +import { defineEventHandler } from '#imports'; + +// A plain route for tests to request after the tunnel traffic. +export default defineEventHandler(() => 'ok'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts index b239562b2194..7f1cd7bd7a1e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -1,11 +1,39 @@ import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); +// First in this file: the isolate's first tunnel request is the one a late span filter would let through. +test('drops the server span of a tunnel request under the base URL', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const tunnelSpanPromise = waitForStreamedSpan( + 'nuxt-4-cloudflare', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '').startsWith('/app/qpo967'), + ); + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-4-cloudflare', + span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/app/api/anchor', + ); + + await request.post('/app/qpo967', { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + // Spans flush in order, so a tunnel span would arrive before this one. + await request.get('/app/api/anchor'); + + const winner = await Promise.race([ + tunnelSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); + test('answers the managed tunnel when the SDK is set up with `sentryCloudflareNitroPlugin`', async ({ request }) => { const dsn = 'http://public@localhost:3031/1337'; // The path the SDK derives from `dsn`. - const response = await request.post('/qpo967', { + const response = await request.post('/app/qpo967', { data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, headers: { 'Content-Type': 'application/x-sentry-envelope' }, }); diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 8df28a8a2550..51f76c39eae3 100644 --- a/packages/nitro/src/module.ts +++ b/packages/nitro/src/module.ts @@ -1,4 +1,4 @@ -import { getClient, setManagedTunnelBasePath } from '@sentry/core'; +import { setManagedTunnelDefaults } from '@sentry/core/server'; import type { NitroModule } from 'nitro/types'; import type { SentryNitroOptions } from './config'; import { instrumentServer } from './instruments/instrumentServer'; @@ -17,7 +17,7 @@ export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnab // In dev, Vite's server takes each request in this process before Nitro handles it, and its `http.server` span // comes from the SDK `--import` set up here. The runtime plugin only reaches the client where Nitro runs. if (nitro.options.dev) { - setManagedTunnelBasePath(getClient(), nitro.options.baseURL); + setManagedTunnelDefaults({ basePath: nitro.options.baseURL }); } }, }; diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index b9e739f10297..2df6b959c4c2 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,4 +1,4 @@ -import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import { definePlugin } from 'nitro'; import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; @@ -11,16 +11,9 @@ export default definePlugin(nitroApp => { // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. - const applyBaseURL = (): void => - setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); - applyBaseURL(); + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => { - // `init` may run after this plugin, so this also covers the first requests after it. - applyBaseURL(); - - return handleTunnelRequestIfMatched(request) ?? appFetch(request); - }; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); captureTracingEvents(); captureStorageEvents(); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index 648eda88c08a..fca620bf1a3d 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,4 +1,4 @@ -import { getClient, handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelBasePath } from '@sentry/core'; +import { handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelDefaults } from '@sentry/core/server'; import { eventHandler, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import { useRuntimeConfig } from 'nitropack/runtime'; @@ -10,17 +10,13 @@ import { useRuntimeConfig } from 'nitropack/runtime'; export default (nitroApp => { // The first layer sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. - const applyBaseURL = (): void => setManagedTunnelBasePath(getClient(), useRuntimeConfig().app?.baseURL); - applyBaseURL(); + setManagedTunnelDefaults({ basePath: useRuntimeConfig().app?.baseURL }); // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule // that layer throws for writing headers on the sent response. nitroApp.h3App.stack.unshift({ route: '', handler: eventHandler(event => { - // `init` may run after this plugin, so this also covers the first requests after it. - applyBaseURL(); - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. if (!isTunnelRequest({ method: event.method, url: event.path })) { return undefined; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 8f57371e9eb6..f84440a06781 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,4 +1,4 @@ -import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import { useRuntimeConfig } from 'nitro/runtime-config'; import type { NitroAppPlugin, NitroApp } from 'nitro/types'; @@ -9,15 +9,9 @@ export default ((nitroApp: NitroApp) => { // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes // first, before route rules and middleware. - // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. - const applyBaseURL = (): void => - setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); - applyBaseURL(); + // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. `useRuntimeConfig` also picks + // up a `NUXT_APP_BASE_URL` set at runtime. + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => { - // `init` may run after this plugin, so this also covers the first requests after it. - applyBaseURL(); - - return handleTunnelRequestIfMatched(request) ?? appFetch(request); - }; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); }) satisfies NitroAppPlugin; From 34649a550c0e29026cb224e274a9bccb8d9ff199 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 12:03:39 -0400 Subject: [PATCH 60/93] feat(astro): Serve the managed tunnel from the Sentry middleware The Sentry middleware runs first and answers tunnel requests. The integration hands Astro's `base` and `trailingSlash: 'always'` to both SDKs, so `tunnel: true` follows Astro's routing without repeating them. --- .../astro-7/astro.config.mjs | 5 ++ .../test-applications/astro-7/package.json | 13 +++- .../astro-7/sentry.client.config.js | 2 +- .../astro-7/sentry.server.config.js | 2 +- .../astro-7/src/middleware.ts | 14 +++++ .../astro-7/tests/tunnel.test.ts | 43 +++++++++++++ packages/astro/src/client/sdk.ts | 2 + packages/astro/src/integration/index.ts | 15 +++-- packages/astro/src/integration/tunnel.ts | 32 ++++++++++ packages/astro/src/server/middleware.ts | 6 ++ packages/astro/src/server/sdk.ts | 2 + packages/astro/src/tunnel.ts | 18 ++++++ packages/astro/test/server/middleware.test.ts | 21 +++++++ packages/astro/test/tunnel.test.ts | 63 +++++++++++++++++++ 14 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts create mode 100644 packages/astro/src/integration/tunnel.ts create mode 100644 packages/astro/src/tunnel.ts create mode 100644 packages/astro/test/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs index 061db869d94b..49370ed1e18b 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; +// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel +// follows both. +const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL; + // https://astro.build/config export default defineConfig({ + ...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }), integrations: [ sentry({ debug: true, diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index 10618dfc3227..9ffc816a0524 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test db" + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -27,6 +29,13 @@ "extends": "../../package.json" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "astro-7 (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js index 83573d36d0be..c0f094f0dd1a 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js @@ -4,7 +4,7 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server integrations: [ Sentry.browserTracingIntegration({ beforeStartSpan: opts => { diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js index bc90470cef38..ae5ecf038b62 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js @@ -4,6 +4,6 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts new file mode 100644 index 000000000000..fefaf0abe0b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts @@ -0,0 +1,14 @@ +import { defineMiddleware } from 'astro:middleware'; + +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. +export const onRequest = defineMiddleware((context, next) => { + if ( + import.meta.env.PUBLIC_E2E_TEST_TUNNEL && + context.request.method === 'POST' && + context.request.headers.get('x-auth') !== 'ok' + ) { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts new file mode 100644 index 000000000000..96d07a6a5291 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; + +test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'astro-7', + span => + getSpanOp(span) === 'http.server' && + /\/docs\/[a-z0-9]{1,7}\/?$/.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/docs/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'astro-7', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '').startsWith('/docs/test-ssr'), + ); + await page.request.get('/docs/test-ssr/'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 14effd00cacb..bd05cbd24579 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { BrowserOptions } from '@sentry/browser'; import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser'; import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; +import { applyAstroTunnelOptions } from '../tunnel'; import { browserTracingIntegration } from './browserTracingIntegration'; import { createAstroRouteProvider } from './routeProvider'; @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'astro', ['astro', 'browser']); + applyAstroTunnelOptions(opts); return initBrowserSdk(opts); } diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 396413b64939..390d28ed92cc 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel'; import type { SentryOptions } from './types'; const PKG_NAME = '@sentry/astro'; @@ -128,15 +129,21 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { }); } + const tunnelOptions = getAstroTunnelOptions(config); + if (tunnelOptions) { + updateConfig({ vite: { plugins: [makeTunnelOptionsVitePlugin(tunnelOptions)] } }); + } + const tunnelOptionsImport = tunnelOptions ? TUNNEL_OPTIONS_IMPORT : ''; + if (sdkEnabled.client) { const pathToClientInit = clientInitPath ? path.resolve(clientInitPath) : findDefaultSdkInitFile('client'); if (pathToClientInit) { debug && logger.info(`Using ${pathToClientInit} for client init.`); - injectScript('page', buildSdkInitFileImportSnippet(pathToClientInit)); + injectScript('page', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToClientInit)); } else { debug && logger.info('Using default client init.'); - injectScript('page', buildClientSnippet(options || {})); + injectScript('page', tunnelOptionsImport + buildClientSnippet(options || {})); } } @@ -182,10 +189,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // Always inject the server config via `injectScript('page-ssr')`. // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) // and also serves as the fallback for non-Cloudflare adapters in production. - injectScript('page-ssr', buildSdkInitFileImportSnippet(pathToServerInit)); + injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit)); } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); - injectScript('page-ssr', buildServerSnippet(options || {})); + injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {})); } if (isCloudflareWorkers && command !== 'dev') { diff --git a/packages/astro/src/integration/tunnel.ts b/packages/astro/src/integration/tunnel.ts new file mode 100644 index 000000000000..d3f0bc2a3d5b --- /dev/null +++ b/packages/astro/src/integration/tunnel.ts @@ -0,0 +1,32 @@ +import type { TunnelOptions } from '@sentry/core'; +import type { AstroConfig } from 'astro'; +import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel'; +import type { VitePlugin } from './cloudflare'; + +const TUNNEL_OPTIONS_MODULE = 'virtual:@sentry/astro/tunnel-options'; +const RESOLVED_TUNNEL_OPTIONS_MODULE = `\0${TUNNEL_OPTIONS_MODULE}`; + +/** The tunnel options Astro's routing requires, or `undefined` if the defaults work. */ +export function getAstroTunnelOptions(config: Pick): TunnelOptions | undefined { + const basePath = config.base?.replace(/\/$/, ''); + const trailingSlash = config.trailingSlash === 'always'; + + return basePath || trailingSlash + ? { ...(basePath && { basePath }), ...(trailingSlash && { trailingSlash }) } + : undefined; +} + +/** Imported first in the injected init scripts, so the options are set before `Sentry.init` runs. */ +export const TUNNEL_OPTIONS_IMPORT = `import "${TUNNEL_OPTIONS_MODULE}";\n`; + +/** Serves the module that hands the tunnel options to the SDKs. */ +export function makeTunnelOptionsVitePlugin(tunnelOptions: TunnelOptions): VitePlugin { + return { + name: 'sentry-astro-tunnel-options', + resolveId: id => (id === TUNNEL_OPTIONS_MODULE ? RESOLVED_TUNNEL_OPTIONS_MODULE : null), + load: id => + id === RESOLVED_TUNNEL_OPTIONS_MODULE + ? `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};` + : null, + }; +} diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 2e88b7fc160a..5bbec51c77be 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -25,6 +25,7 @@ import { winterCGRequestToRequestData, filterCollectedUrl, filterCollectedUrlQuery, + handleTunnelRequestIfMatched, } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import { @@ -94,6 +95,11 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } + const tunnelResponse = handleTunnelRequestIfMatched(ctx.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); // For static (prerendered) routes, we only want to inject the parametrized route meta tags diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 6ba2703bc994..aea532c3eaa5 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -2,6 +2,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; +import { applyAstroTunnelOptions } from '../tunnel'; /** * @@ -13,6 +14,7 @@ export function init(options: NodeOptions): NodeClient | undefined { }; applySdkMetadata(opts, 'astro', ['astro', 'node']); + applyAstroTunnelOptions(opts); opts.ignoreSpans = [ ...(opts.ignoreSpans || []), diff --git a/packages/astro/src/tunnel.ts b/packages/astro/src/tunnel.ts new file mode 100644 index 000000000000..bbccfbbc9471 --- /dev/null +++ b/packages/astro/src/tunnel.ts @@ -0,0 +1,18 @@ +import type { Options, TunnelOptions } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; + +/** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */ +export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; + +/** + * Serves the managed tunnel under the app's base path and with Astro's trailing slash policy, which Astro enforces + * before any middleware runs. Options the user set win. + */ +export function applyAstroTunnelOptions(options: Pick): void { + const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: TunnelOptions })[TUNNEL_OPTIONS_GLOBAL]; + if (!astroTunnelOptions || !options.tunnel || typeof options.tunnel === 'string') { + return; + } + + options.tunnel = { ...astroTunnelOptions, ...(options.tunnel === true ? {} : options.tunnel) }; +} diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 88a21e303f10..b8b264c61723 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -33,6 +33,27 @@ const STATIC_REQUEST_CONTEXT = { url: new URL('https://myDomain.io/users/'), }; +describe('sentryMiddleware with the managed tunnel', () => { + it('answers tunnel requests without calling the rest of the middleware chain', async () => { + const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); + const tunnelResponse = new Response('forwarded'); + const matcherSpy = vi + .spyOn(SentryCore, 'handleTunnelRequestIfMatched') + .mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); + + const response = await handleRequest()({ ...DYNAMIC_REQUEST_CONTEXT, request } as unknown as APIContext, next); + + expect(response).toBe(tunnelResponse); + expect(matcherSpy).toHaveBeenCalledWith(request); + expect(next).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); + matcherSpy.mockRestore(); + }); +}); + describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); diff --git a/packages/astro/test/tunnel.test.ts b/packages/astro/test/tunnel.test.ts new file mode 100644 index 000000000000..1752a3444dbe --- /dev/null +++ b/packages/astro/test/tunnel.test.ts @@ -0,0 +1,63 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin } from '../src/integration/tunnel'; +import { applyAstroTunnelOptions, TUNNEL_OPTIONS_GLOBAL } from '../src/tunnel'; + +const globalWithOptions = GLOBAL_OBJ as { __sentry_astro_tunnel_options?: unknown }; + +describe('getAstroTunnelOptions', () => { + it('maps `base` and `trailingSlash: always` onto tunnel options', () => { + expect(getAstroTunnelOptions({ base: '/docs/', trailingSlash: 'always' })).toEqual({ + basePath: '/docs', + trailingSlash: true, + }); + expect(getAstroTunnelOptions({ base: '/docs', trailingSlash: 'ignore' })).toEqual({ basePath: '/docs' }); + }); + + it('returns nothing for the defaults', () => { + expect(getAstroTunnelOptions({ base: '/', trailingSlash: 'ignore' })).toBeUndefined(); + }); +}); + +describe('makeTunnelOptionsVitePlugin', () => { + it('serves a module that sets the tunnel options global', () => { + const plugin = makeTunnelOptionsVitePlugin({ basePath: '/docs' }); + const resolved = (plugin.resolveId as unknown as (id: string) => string | null)( + 'virtual:@sentry/astro/tunnel-options', + ); + + expect((plugin.load as unknown as (id: string) => string | null)(resolved!)).toBe( + `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = {"basePath":"/docs"};`, + ); + }); +}); + +describe('applyAstroTunnelOptions', () => { + afterEach(() => { + delete globalWithOptions.__sentry_astro_tunnel_options; + }); + + it('adds the Astro routing options to the managed tunnel, letting user options win', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true }; + const fromTrue: Parameters[0] = { tunnel: true }; + const fromOptions: Parameters[0] = { tunnel: { basePath: '/other' } }; + + applyAstroTunnelOptions(fromTrue); + applyAstroTunnelOptions(fromOptions); + + expect(fromTrue.tunnel).toEqual({ basePath: '/docs', trailingSlash: true }); + expect(fromOptions.tunnel).toEqual({ basePath: '/other', trailingSlash: true }); + }); + + it('leaves URL tunnels and a disabled tunnel alone', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs' }; + const url: Parameters[0] = { tunnel: '/monitor' }; + const off: Parameters[0] = {}; + + applyAstroTunnelOptions(url); + applyAstroTunnelOptions(off); + + expect(url.tunnel).toBe('/monitor'); + expect(off.tunnel).toBeUndefined(); + }); +}); From b4453e3c309735e93796b905a6de948994f4bd02 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 23:32:30 -0400 Subject: [PATCH 61/93] fix(astro): Run the server init before the middleware so the tunnel works on a cold server --- .../astro-7/tests/tunnel.test.ts | 8 ++++++ packages/astro/src/integration/index.ts | 19 +++++++++----- .../src/integration/middleware/virtual.ts | 22 ++++++++++++++++ packages/astro/test/integration/index.test.ts | 25 ++++++++++++++++++- 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 packages/astro/src/integration/middleware/virtual.ts diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts index 96d07a6a5291..b45aac26311c 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts @@ -3,9 +3,17 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); +const DSN = 'http://public@localhost:3031/1337'; const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => { + // The first request this server sees, so no SSR page has run `Sentry.init` yet. + const coldTunnelResponse = await page.request.post('/docs/qpo967/', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: DSN })}\n`, + }); + expect(coldTunnelResponse.status()).toBe(200); + // The variant's auth middleware rejects every other POST. expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401); diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 390d28ed92cc..e10660163f36 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -7,6 +7,7 @@ import { createRequire } from 'module'; import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; +import { makeSentryMiddlewareVitePlugin, SENTRY_MIDDLEWARE_MODULE } from './middleware/virtual'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel'; import type { SentryOptions } from './types'; @@ -181,18 +182,23 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } } + let serverInitSnippet = ''; if (sdkEnabled.server) { const pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server'); if (pathToServerInit) { debug && logger.info(`Using ${pathToServerInit} for server init.`); - // Always inject the server config via `injectScript('page-ssr')`. - // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) - // and also serves as the fallback for non-Cloudflare adapters in production. - injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit)); + serverInitSnippet = tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit); } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); - injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {})); + serverInitSnippet = tunnelOptionsImport + buildServerSnippet(options || {}); + } + + // Always inject the server config via `injectScript('page-ssr')`. + // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) + // and also serves as the fallback for non-Cloudflare adapters in production. + if (serverInitSnippet) { + injectScript('page-ssr', serverInitSnippet); } if (isCloudflareWorkers && command !== 'dev') { @@ -239,9 +245,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false; if (isSSR && shouldAddMiddleware) { + updateConfig({ vite: { plugins: [makeSentryMiddlewareVitePlugin(serverInitSnippet)] } }); addMiddleware({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: SENTRY_MIDDLEWARE_MODULE, }); } }, diff --git a/packages/astro/src/integration/middleware/virtual.ts b/packages/astro/src/integration/middleware/virtual.ts new file mode 100644 index 000000000000..b5122a4a3e61 --- /dev/null +++ b/packages/astro/src/integration/middleware/virtual.ts @@ -0,0 +1,22 @@ +import type { VitePlugin } from '../cloudflare'; + +/** + * The middleware entrypoint the integration registers instead of `@sentry/astro/middleware`. + */ +export const SENTRY_MIDDLEWARE_MODULE = 'virtual:@sentry/astro/middleware'; +const RESOLVED_SENTRY_MIDDLEWARE_MODULE = `\0${SENTRY_MIDDLEWARE_MODULE}`; + +/** + * Serves the Sentry middleware behind the server init. `page-ssr` scripts only run once an SSR page loads, so without + * this a request that renders no page, like a tunnel request on a cold serverless instance, would find no client. + */ +export function makeSentryMiddlewareVitePlugin(serverInitSnippet: string): VitePlugin { + return { + name: 'sentry-astro-middleware', + resolveId: id => (id === SENTRY_MIDDLEWARE_MODULE ? RESOLVED_SENTRY_MIDDLEWARE_MODULE : null), + load: id => + id === RESOLVED_SENTRY_MIDDLEWARE_MODULE + ? `${serverInitSnippet}\nexport { onRequest } from "@sentry/astro/middleware";\n` + : null, + }; +} diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 42e9af789247..1074f2f51032 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -680,10 +680,33 @@ describe('sentryAstro integration', () => { expect(addMiddleware).toHaveBeenCalledTimes(1); expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: 'virtual:@sentry/astro/middleware', }); }); + it('runs the server init before the middleware, so requests that render no page find a client', async () => { + const integration = sentryAstro({}); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + // @ts-expect-error - we only need to pass what we actually use + config: { output: 'server' }, + addMiddleware: vi.fn(), + updateConfig, + injectScript, + }); + + const plugin = updateConfig.mock.calls + .flatMap(([config]) => config.vite?.plugins ?? []) + .find(plugin => plugin.name === 'sentry-astro-middleware'); + const resolvedId = plugin.resolveId('virtual:@sentry/astro/middleware'); + const serverInit = injectScript.mock.calls.find(([stage]) => stage === 'page-ssr')?.[1]; + + expect(plugin.load(resolvedId)).toBe(`${serverInit}\nexport { onRequest } from "@sentry/astro/middleware";\n`); + }); + it.each([{ output: 'static' }, { output: undefined }])( "doesn't add middleware if in static mode (config %s)", async (config: any) => { From a1c95e470e819ee035fa5792f0dac3daf4342b85 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:08:51 -0400 Subject: [PATCH 62/93] fix(astro): Serve a relative `tunnel` path under the base path too --- packages/astro/src/tunnel.ts | 8 ++------ packages/astro/test/tunnel.test.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/astro/src/tunnel.ts b/packages/astro/src/tunnel.ts index bbccfbbc9471..e24da0785fdf 100644 --- a/packages/astro/src/tunnel.ts +++ b/packages/astro/src/tunnel.ts @@ -1,5 +1,5 @@ import type { Options, TunnelOptions } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; /** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */ export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; @@ -10,9 +10,5 @@ export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; */ export function applyAstroTunnelOptions(options: Pick): void { const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: TunnelOptions })[TUNNEL_OPTIONS_GLOBAL]; - if (!astroTunnelOptions || !options.tunnel || typeof options.tunnel === 'string') { - return; - } - - options.tunnel = { ...astroTunnelOptions, ...(options.tunnel === true ? {} : options.tunnel) }; + options.tunnel = applyTunnelDefaults(options.tunnel, astroTunnelOptions); } diff --git a/packages/astro/test/tunnel.test.ts b/packages/astro/test/tunnel.test.ts index 1752a3444dbe..9aaf293c3e11 100644 --- a/packages/astro/test/tunnel.test.ts +++ b/packages/astro/test/tunnel.test.ts @@ -49,15 +49,24 @@ describe('applyAstroTunnelOptions', () => { expect(fromOptions.tunnel).toEqual({ basePath: '/other', trailingSlash: true }); }); + it('serves a relative tunnel path with the Astro routing options too', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true }; + const options: Parameters[0] = { tunnel: '/monitor' }; + + applyAstroTunnelOptions(options); + + expect(options.tunnel).toEqual({ basePath: '/docs', trailingSlash: true, path: '/monitor' }); + }); + it('leaves URL tunnels and a disabled tunnel alone', () => { globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs' }; - const url: Parameters[0] = { tunnel: '/monitor' }; + const url: Parameters[0] = { tunnel: 'https://example.com/tunnel' }; const off: Parameters[0] = {}; applyAstroTunnelOptions(url); applyAstroTunnelOptions(off); - expect(url.tunnel).toBe('/monitor'); + expect(url.tunnel).toBe('https://example.com/tunnel'); expect(off.tunnel).toBeUndefined(); }); }); From a0a410244584a98753d8d03bc14a5df8e8769057 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:51:29 -0400 Subject: [PATCH 63/93] test(astro): Check the managed tunnel under `astro dev` too --- .../e2e-tests/test-applications/astro-7/package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index 9ffc816a0524..8a4fc043ce10 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -13,7 +13,8 @@ "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test db", "test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", - "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" + "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -35,6 +36,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "astro-7 (tunnel)" + }, + { + "label": "astro-7 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" } ] } From 4d9870021eadec68239053dc2f74309107a68eb1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:19:59 -0400 Subject: [PATCH 64/93] revert(astro): Load the server init from SSR pages only, as on develop This reverts commit 6647a68945. The middleware module ran the inline default init a second time, and running the init before a cold non-page request is a gap develop has for every server request, not one the tunnel adds. --- .../astro-7/tests/tunnel.test.ts | 8 ------ packages/astro/src/integration/index.ts | 19 +++++--------- .../src/integration/middleware/virtual.ts | 22 ---------------- packages/astro/test/integration/index.test.ts | 25 +------------------ 4 files changed, 7 insertions(+), 67 deletions(-) delete mode 100644 packages/astro/src/integration/middleware/virtual.ts diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts index b45aac26311c..96d07a6a5291 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts @@ -3,17 +3,9 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -const DSN = 'http://public@localhost:3031/1337'; const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => { - // The first request this server sees, so no SSR page has run `Sentry.init` yet. - const coldTunnelResponse = await page.request.post('/docs/qpo967/', { - headers: { 'content-type': 'application/x-sentry-envelope' }, - data: `${JSON.stringify({ dsn: DSN })}\n`, - }); - expect(coldTunnelResponse.status()).toBe(200); - // The variant's auth middleware rejects every other POST. expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401); diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index e10660163f36..390d28ed92cc 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -7,7 +7,6 @@ import { createRequire } from 'module'; import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; -import { makeSentryMiddlewareVitePlugin, SENTRY_MIDDLEWARE_MODULE } from './middleware/virtual'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel'; import type { SentryOptions } from './types'; @@ -182,23 +181,18 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } } - let serverInitSnippet = ''; if (sdkEnabled.server) { const pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server'); if (pathToServerInit) { debug && logger.info(`Using ${pathToServerInit} for server init.`); - serverInitSnippet = tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit); + // Always inject the server config via `injectScript('page-ssr')`. + // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) + // and also serves as the fallback for non-Cloudflare adapters in production. + injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit)); } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); - serverInitSnippet = tunnelOptionsImport + buildServerSnippet(options || {}); - } - - // Always inject the server config via `injectScript('page-ssr')`. - // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) - // and also serves as the fallback for non-Cloudflare adapters in production. - if (serverInitSnippet) { - injectScript('page-ssr', serverInitSnippet); + injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {})); } if (isCloudflareWorkers && command !== 'dev') { @@ -245,10 +239,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false; if (isSSR && shouldAddMiddleware) { - updateConfig({ vite: { plugins: [makeSentryMiddlewareVitePlugin(serverInitSnippet)] } }); addMiddleware({ order: 'pre', - entrypoint: SENTRY_MIDDLEWARE_MODULE, + entrypoint: '@sentry/astro/middleware', }); } }, diff --git a/packages/astro/src/integration/middleware/virtual.ts b/packages/astro/src/integration/middleware/virtual.ts deleted file mode 100644 index b5122a4a3e61..000000000000 --- a/packages/astro/src/integration/middleware/virtual.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { VitePlugin } from '../cloudflare'; - -/** - * The middleware entrypoint the integration registers instead of `@sentry/astro/middleware`. - */ -export const SENTRY_MIDDLEWARE_MODULE = 'virtual:@sentry/astro/middleware'; -const RESOLVED_SENTRY_MIDDLEWARE_MODULE = `\0${SENTRY_MIDDLEWARE_MODULE}`; - -/** - * Serves the Sentry middleware behind the server init. `page-ssr` scripts only run once an SSR page loads, so without - * this a request that renders no page, like a tunnel request on a cold serverless instance, would find no client. - */ -export function makeSentryMiddlewareVitePlugin(serverInitSnippet: string): VitePlugin { - return { - name: 'sentry-astro-middleware', - resolveId: id => (id === SENTRY_MIDDLEWARE_MODULE ? RESOLVED_SENTRY_MIDDLEWARE_MODULE : null), - load: id => - id === RESOLVED_SENTRY_MIDDLEWARE_MODULE - ? `${serverInitSnippet}\nexport { onRequest } from "@sentry/astro/middleware";\n` - : null, - }; -} diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 1074f2f51032..42e9af789247 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -680,33 +680,10 @@ describe('sentryAstro integration', () => { expect(addMiddleware).toHaveBeenCalledTimes(1); expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', - entrypoint: 'virtual:@sentry/astro/middleware', + entrypoint: '@sentry/astro/middleware', }); }); - it('runs the server init before the middleware, so requests that render no page find a client', async () => { - const integration = sentryAstro({}); - const updateConfig = vi.fn(); - const injectScript = vi.fn(); - - // @ts-expect-error - the hook exists and we only need to pass what we actually use - await integration.hooks['astro:config:setup']({ - // @ts-expect-error - we only need to pass what we actually use - config: { output: 'server' }, - addMiddleware: vi.fn(), - updateConfig, - injectScript, - }); - - const plugin = updateConfig.mock.calls - .flatMap(([config]) => config.vite?.plugins ?? []) - .find(plugin => plugin.name === 'sentry-astro-middleware'); - const resolvedId = plugin.resolveId('virtual:@sentry/astro/middleware'); - const serverInit = injectScript.mock.calls.find(([stage]) => stage === 'page-ssr')?.[1]; - - expect(plugin.load(resolvedId)).toBe(`${serverInit}\nexport { onRequest } from "@sentry/astro/middleware";\n`); - }); - it.each([{ output: 'static' }, { output: undefined }])( "doesn't add middleware if in static mode (config %s)", async (config: any) => { From bbc97ee81e8ccb95d98c72f6dbc27a91e3fadd78 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:16 -0400 Subject: [PATCH 65/93] ref(astro): Import the tunnel handler from @sentry/core/server --- packages/astro/src/server/middleware.ts | 3 +-- packages/astro/test/server/middleware.test.ts | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 5bbec51c77be..a0f99392679e 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -25,9 +25,8 @@ import { winterCGRequestToRequestData, filterCollectedUrl, filterCollectedUrlQuery, - handleTunnelRequestIfMatched, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; +import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server'; import { captureException, continueTrace, diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index b8b264c61723..573ef640e460 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -2,6 +2,7 @@ import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } f import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; +import * as SentryCoreServer from '@sentry/core/server'; import * as SentryNode from '@sentry/node'; import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -38,7 +39,7 @@ describe('sentryMiddleware with the managed tunnel', () => { const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); const tunnelResponse = new Response('forwarded'); const matcherSpy = vi - .spyOn(SentryCore, 'handleTunnelRequestIfMatched') + .spyOn(SentryCoreServer, 'handleTunnelRequestIfMatched') .mockReturnValue(Promise.resolve(tunnelResponse)); const next = vi.fn(); const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); From a45041bf00f46d45ddae440059dc508cb97a12d2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 12:12:37 -0400 Subject: [PATCH 66/93] feat(remix): Serve the managed tunnel from the Remix 3 router middleware Sentry's middleware is already prepended to every `createRouter`, so it now answers tunnel requests before route matching and the app's own middleware. --- .../remix-v3/app/actions/public/entry.ts | 2 +- .../test-applications/remix-v3/app/assets.ts | 1 + .../test-applications/remix-v3/app/router.ts | 15 ++++++- .../test-applications/remix-v3/package.json | 12 +++++- .../test-applications/remix-v3/server.ts | 2 +- .../remix-v3/tests/tunnel.test.ts | 40 +++++++++++++++++++ packages/remix/src/v3/server/middleware.ts | 6 +++ packages/remix/test/v3/middleware.test.ts | 19 +++++++++ 8 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index bbeee228783a..89bdd61f5491 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -9,7 +9,7 @@ declare const process: { env: Record }; Sentry.init({ dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3061/', + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/', tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index 345e6b2eaffd..4bfe6672caf4 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -15,6 +15,7 @@ export const assets = createAssetServer({ // into a browser module. The asset server substitutes these when it compiles. define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), + 'process.env.E2E_TEST_TUNNEL': JSON.stringify(process.env.E2E_TEST_TUNNEL ?? ''), }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts index 03724dd7ea27..2102a5768372 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts @@ -14,8 +14,21 @@ declare module 'remix/router' { } } +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. +function authMiddleware(context: { request: Request }, next: () => Promise): Promise | Response { + if ( + process.env.E2E_TEST_TUNNEL && + context.request.method === 'POST' && + context.request.headers.get('x-auth') !== 'ok' + ) { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); +} + export const router = createRouter({ - middleware: [renderMiddleware], + middleware: [authMiddleware, renderMiddleware], }); router.map(routes, controller); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json index fe19e83132f1..da7831948456 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -8,7 +8,8 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3061/1337 playwright test tunnel" }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", @@ -23,5 +24,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build", + "assert-command": "pnpm test:assert:tunnel", + "label": "remix-v3 (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts index c9ac4f00d087..9d500cc2a808 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts @@ -6,7 +6,7 @@ import { router } from './app/router.ts'; Sentry.init({ dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3061/', + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/', tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts new file mode 100644 index 000000000000..f3daf7506f8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'remix-v3'; +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel before route matching and the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/anything', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/items/1', + ); + await page.request.get('/api/items/1'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index f2ce59c48f01..3a4d32e90126 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -5,6 +5,7 @@ import { getRootSpan, getSpanStatusFromHttpCode, getTraceData, + handleTunnelRequestIfMatched, INTERNAL_setSegmentNameSourceIfSegment, type Scope, updateSpanName, @@ -25,6 +26,11 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { + const tunnelResponse = handleTunnelRequestIfMatched(context.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isolationScope = getIsolationScope(); isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) }); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 3bc7031300bf..bc033051b6c4 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -4,9 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Trace data as the SDK would produce it for the active request; empty when nothing is active. const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({}))); +const handleTunnelRequestIfMatched = vi.hoisted(() => + vi.fn<(request: Request) => Promise | undefined>(() => undefined), +); + vi.mock('@sentry/core', async importOriginal => ({ ...(await importOriginal()), getTraceData, + handleTunnelRequestIfMatched, })); import { sentryRemixMiddleware } from '../../src/v3/server/middleware'; @@ -27,6 +32,20 @@ function contextFor(url: string, headers: Record = {}): RequestC describe('sentryRemixMiddleware', () => { beforeEach(() => { getTraceData.mockReturnValue({}); + handleTunnelRequestIfMatched.mockReturnValue(undefined); + }); + + it('answers tunnel requests before route matching and the rest of the chain', async () => { + const tunnelResponse = new Response('forwarded'); + handleTunnelRequestIfMatched.mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const context = contextFor('http://x/abc123'); + + const response = await sentryRemixMiddleware(matcherFor('/'))(context, next); + + expect(response).toBe(tunnelResponse); + expect(handleTunnelRequestIfMatched).toHaveBeenCalledWith(context.request); + expect(next).not.toHaveBeenCalled(); }); it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { From c2a06f6742a6c2f1ec86b57305134dd045cae31d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:22 -0400 Subject: [PATCH 67/93] ref(remix): Import the tunnel handler from @sentry/core/server --- packages/remix/src/v3/server/middleware.ts | 2 +- packages/remix/test/v3/middleware.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 3a4d32e90126..b5803af44dcb 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -5,12 +5,12 @@ import { getRootSpan, getSpanStatusFromHttpCode, getTraceData, - handleTunnelRequestIfMatched, INTERNAL_setSegmentNameSourceIfSegment, type Scope, updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; +import { handleTunnelRequestIfMatched } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index bc033051b6c4..c86d426793e9 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -1,4 +1,5 @@ import type * as SentryCore from '@sentry/core'; +import type * as SentryCoreServer from '@sentry/core/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Trace data as the SDK would produce it for the active request; empty when nothing is active. @@ -11,6 +12,10 @@ const handleTunnelRequestIfMatched = vi.hoisted(() => vi.mock('@sentry/core', async importOriginal => ({ ...(await importOriginal()), getTraceData, +})); + +vi.mock('@sentry/core/server', async importOriginal => ({ + ...(await importOriginal()), handleTunnelRequestIfMatched, })); From 6069ffecfebf4ba40f273ed96a705424d95cb1d1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 12:43:44 -0400 Subject: [PATCH 68/93] feat(react-router): Serve the managed tunnel from a route in the server build React Router has no request hook to answer from, so `sentryReactRouter()` adds a resource route whose path is read from the DSN at runtime. React Router reads it on its first request, so a request before `Sentry.init` keeps the tunnel off until restart. --- .../app/entry.client.tsx | 4 +- .../react-router-8-framework/app/globals.d.ts | 1 + .../react-router-8-framework/app/root.tsx | 14 +++++ .../react-router-8-framework/instrument.mjs | 4 +- .../react-router-8-framework/package.json | 9 ++- .../tests/tunnel.test.ts | 40 +++++++++++++ .../react-router-8-framework/vite.config.ts | 3 + packages/react-router/src/cloudflare/index.ts | 1 + packages/react-router/src/server/index.ts | 1 + .../react-router/src/server/tunnelRoute.ts | 38 +++++++++++++ .../src/vite/makeTunnelRoutePlugin.ts | 28 +++++++++ packages/react-router/src/vite/plugin.ts | 2 + .../test/server/tunnelRoute.test.ts | 57 +++++++++++++++++++ .../test/vite/makeTunnelRoutePlugin.test.ts | 20 +++++++ .../react-router/test/vite/plugin.test.ts | 11 +++- 15 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts create mode 100644 packages/react-router/src/server/tunnelRoute.ts create mode 100644 packages/react-router/src/vite/makeTunnelRoutePlugin.ts create mode 100644 packages/react-router/test/server/tunnelRoute.test.ts create mode 100644 packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx index 005268b40ad0..0215cdd728b9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx @@ -6,8 +6,8 @@ import { HydratedRouter } from 'react-router/dom'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions // todo: get this from env - dsn: 'https://username@domain/123', - tunnel: `http://localhost:3031/`, // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, tracePropagationTargets: [/^\//], diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts new file mode 100644 index 000000000000..dc2e8cbcb587 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts @@ -0,0 +1 @@ +declare const __E2E_TEST_TUNNEL__: boolean; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx index bc1b8f1236c0..3ae4b730b9e1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx @@ -2,6 +2,20 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } import type { Route } from './+types/root'; import stylesheet from './app.css?url'; +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. Other variants +// get none, so their span assertions stay as they are. +export const middleware: Route.MiddlewareFunction[] = __E2E_TEST_TUNNEL__ + ? [ + ({ request }, next) => { + if (request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); + }, + ] + : []; + export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs index c16240141b6d..9f2ece437fd1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - dsn: 'https://username@domain/123', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 552e505b3457..0dc083191099 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -44,7 +44,9 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:ts && pnpm test:playwright", "test:ts": "pnpm typecheck", - "test:playwright": "playwright test" + "test:playwright": "playwright test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -85,6 +87,11 @@ { "runtime": "cloudflare", "label": "react-router-8-framework (cloudflare)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "react-router-8-framework (tunnel)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts new file mode 100644 index 000000000000..cf5b3322993f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'react-router-8-framework'; +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel before the root middleware', async ({ page }) => { + // The variant's root middleware rejects every other POST. + expect((await page.request.post('/', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/performance', + ); + await page.request.get('/performance'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts index 0fe4cdde8c7a..3663eb2a74b5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vite'; export default defineConfig(config => { return { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [reactRouter(), sentryReactRouter({ sourcemaps: { disable: true } }, config)], }; }); diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index b94430728125..d691eac21ed2 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -4,6 +4,7 @@ import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; +export { createSentryTunnelRoute } from '../server/tunnelRoute'; export { createSentryServerInstrumentation, type CreateSentryServerInstrumentationOptions, diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 8245169f01a7..99fb32c72ad3 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -8,6 +8,7 @@ export { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; +export { createSentryTunnelRoute } from './tunnelRoute'; // React Router instrumentation API support export { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts new file mode 100644 index 000000000000..1c281cc46adb --- /dev/null +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -0,0 +1,38 @@ +import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched } from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; + +// Never matches a request, for when the managed tunnel is off. +const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; + +/** + * A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React + * Router has no request hook to answer from. Not meant to be used directly. + */ +export function createSentryTunnelRoute(): Record { + return { + id: 'sentry-tunnel', + parentId: undefined, + index: undefined, + caseSensitive: undefined, + // React Router reads the path once, on its first request, and keeps it until the server restarts. + get path(): string { + if (!getClient()) { + DEBUG_BUILD && + debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); + return DISABLED_TUNNEL_PATH; + } + + const [path, ...otherPaths] = getTunnelPaths(); + if (otherPaths.length) { + DEBUG_BUILD && debug.warn(`React Router can only serve one managed tunnel path, so only ${path} is served.`); + } + + return path ?? DISABLED_TUNNEL_PATH; + }, + module: { + action: ({ request }: { request: Request }) => + handleTunnelRequestIfMatched(request) ?? new Response(null, { status: 404 }), + loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), + }, + }; +} diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts new file mode 100644 index 000000000000..c8ed968d5e76 --- /dev/null +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -0,0 +1,28 @@ +import type { Plugin } from 'vite'; + +const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; + +/** + * Adds the managed tunnel route to the server build. It only serves requests when `Sentry.init` enables the + * managed tunnel. + */ +export function makeTunnelRoutePlugin(): Plugin { + return { + name: 'sentry-react-router-tunnel-route', + enforce: 'post', + + transform(code, id, options) { + if (!options?.ssr || !id.includes(SERVER_BUILD_MODULE_ID)) { + return null; + } + + return { + code: `${code} +import { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router'; +routes['sentry-tunnel'] = __sentryCreateTunnelRoute(); +`, + map: null, + }; + }, + }; +} diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 602ca6f4c412..2992b9a5bbc0 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -4,6 +4,7 @@ import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from './makeServerBuildCapturePlugin'; +import { makeTunnelRoutePlugin } from './makeTunnelRoutePlugin'; import type { SentryReactRouterBuildOptions } from './types'; /** @@ -21,6 +22,7 @@ export async function sentryReactRouter( plugins.push(makeConfigInjectorPlugin(options)); plugins.push(makeServerBuildCapturePlugin()); + plugins.push(makeTunnelRoutePlugin()); if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..b540cf6c5c7e --- /dev/null +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -0,0 +1,57 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoute } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +type TunnelRoute = { + path: string; + parentId: undefined; + module: { action: (args: { request: Request }) => Promise | Response; loader: () => Response }; +}; + +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('createSentryTunnelRoute', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + }); + + it('is a top-level route at the tunnel path when `init` enabled the managed tunnel', () => { + setClient({}); + const route = createSentryTunnelRoute() as TunnelRoute; + + expect(route.parentId).toBeUndefined(); + expect(route.path).toBe(getTunnelPath(DSN)); + }); + + it('serves a path that never matches when the tunnel is off or Sentry is not initialized yet', () => { + const route = createSentryTunnelRoute() as TunnelRoute; + const pathWithoutClient = route.path; + setClient(undefined); + + expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); + expect(route.path).toBe('/__sentry-tunnel-disabled__'); + }); + + it('forwards tunnel POSTs and rejects other methods', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = createSentryTunnelRoute() as TunnelRoute; + const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { + method: 'POST', + body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + + expect(await (await route.module.action({ request })).text()).toBe('forwarded'); + expect(route.module.loader().status).toBe(405); + }); +}); diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts new file mode 100644 index 000000000000..758d0a6f56b6 --- /dev/null +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; + +type Transform = (code: string, id: string, options?: { ssr?: boolean }) => { code: string } | null; + +describe('makeTunnelRoutePlugin', () => { + const transform = makeTunnelRoutePlugin().transform as unknown as Transform; + + it('adds the tunnel route to the SSR server build', () => { + const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); + + expect(result?.code).toContain("routes['sentry-tunnel'] = __sentryCreateTunnelRoute();"); + expect(result?.code).toContain("from '@sentry/react-router'"); + }); + + it('leaves other modules and the client build alone', () => { + expect(transform('', '\0virtual:react-router/server-build', { ssr: false })).toBeNull(); + expect(transform('', '/app/root.tsx', { ssr: true })).toBeNull(); + }); +}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 52cbecceb9a9..5c33619821df 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -3,6 +3,7 @@ import { makeConfigInjectorPlugin } from '../../src/vite/makeConfigInjectorPlugi import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from '../../src/vite/makeServerBuildCapturePlugin'; +import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; import { sentryReactRouter } from '../../src/vite/plugin'; vi.spyOn(console, 'log').mockImplementation(() => { @@ -16,6 +17,7 @@ vi.mock('../../src/vite/makeCustomSentryVitePlugins'); vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); vi.mock('../../src/vite/makeConfigInjectorPlugin'); vi.mock('../../src/vite/makeServerBuildCapturePlugin'); +vi.mock('../../src/vite/makeTunnelRoutePlugin'); // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. @@ -31,6 +33,7 @@ describe('sentryReactRouter', () => { const mockSourceMapsPlugin = { name: 'source-maps-plugin' }; const mockConfigInjectorPlugin = { name: 'sentry-config-injector' }; const mockServerBuildCapturePlugin = { name: 'sentry-react-router-server-build-capture' }; + const mockTunnelRoutePlugin = { name: 'sentry-react-router-tunnel-route' }; const mockOrchestrionPlugin = { name: 'sentry-orchestrion-vite' }; beforeEach(() => { @@ -39,6 +42,7 @@ describe('sentryReactRouter', () => { vi.mocked(makeEnableSourceMapsPlugin).mockReturnValue(mockSourceMapsPlugin); vi.mocked(makeConfigInjectorPlugin).mockReturnValue(mockConfigInjectorPlugin); vi.mocked(makeServerBuildCapturePlugin).mockReturnValue(mockServerBuildCapturePlugin); + vi.mocked(makeTunnelRoutePlugin).mockReturnValue(mockTunnelRoutePlugin); }); afterEach(() => { @@ -51,7 +55,7 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); @@ -61,7 +65,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -69,7 +73,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -83,6 +87,7 @@ describe('sentryReactRouter', () => { expect(result).toEqual([ mockConfigInjectorPlugin, mockServerBuildCapturePlugin, + mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsPlugin, ...mockPlugins, From 13d36b46fd3d0478fc4fbcb392e9b36dc49b6d3a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:15:49 -0400 Subject: [PATCH 69/93] test(react-router): Define the tunnel flag in the Cloudflare build --- .../react-router-8-framework/vite.cloudflare.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts index 002bb3a77fbe..032293400dbf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -5,6 +5,9 @@ import { sentryReactRouter } from '@sentry/react-router/vite'; import { defineConfig } from 'vite'; export default defineConfig(async config => ({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ cloudflare({ viteEnvironment: { name: 'ssr' } }), reactRouter(), From eb1d9189a1fd4ebde293fac854937d823ace0c9a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:11:01 -0400 Subject: [PATCH 70/93] fix(react-router): Serve the managed tunnel under the app's `basename` --- .../react-router-8-framework/package.json | 9 ++++++++- .../react-router.config.ts | 2 ++ .../tests/tunnel.test.ts | 13 ++++++++----- packages/react-router/src/client/sdk.ts | 11 ++++++++++- packages/react-router/src/server/tunnelRoute.ts | 14 +++++++++++--- .../src/vite/makeTunnelRoutePlugin.ts | 4 ++-- packages/react-router/test/client/sdk.test.ts | 12 ++++++++++++ .../test/server/tunnelRoute.test.ts | 17 +++++++++++++++++ .../test/vite/makeTunnelRoutePlugin.test.ts | 4 +++- 9 files changed, 73 insertions(+), 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 0dc083191099..3d9063bb6d0a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -46,7 +46,9 @@ "test:ts": "pnpm typecheck", "test:playwright": "playwright test", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel" + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -92,6 +94,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "react-router-8-framework (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "react-router-8-framework (tunnel-basepath)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts index bb1f96469dd2..a74ae0b46048 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts @@ -2,5 +2,7 @@ import type { Config } from '@react-router/dev/config'; export default { ssr: true, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { basename: `${process.env.E2E_TEST_BASE_PATH}/` }), prerender: ['/performance/static'], } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts index cf5b3322993f..03f309305e78 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts @@ -4,11 +4,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); const APP_NAME = 'react-router-8-framework'; -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); test('answers the managed tunnel before the root middleware', async ({ page }) => { // The variant's root middleware rejects every other POST. - expect((await page.request.post('/', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( APP_NAME, @@ -18,7 +19,7 @@ test('answers the managed tunnel before the root middleware', async ({ page }) = response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,11 @@ test('answers the managed tunnel before the root middleware', async ({ page }) = // would arrive first. const anchorSpanPromise = waitForStreamedSpan( APP_NAME, - span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/performance', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/performance`, ); - await page.request.get('/performance'); + await page.request.get(`${BASE_PATH}/performance`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts index a9b13f2e7cd0..0452bee053ce 100644 --- a/packages/react-router/src/client/sdk.ts +++ b/packages/react-router/src/client/sdk.ts @@ -1,10 +1,15 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata, consoleSandbox, setTag } from '@sentry/core'; +import { applySdkMetadata, applyTunnelDefaults, consoleSandbox, GLOBAL_OBJ, setTag } from '@sentry/core'; const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; +// React Router inlines this context before the client entry runs, so it's there before `init`. +const GLOBAL_OBJ_WITH_ROUTER_CONTEXT = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __reactRouterContext?: { basename?: string }; +}; + /** * Initializes the client side of the React Router SDK. */ @@ -25,6 +30,10 @@ export function init(options: BrowserOptions): Client | undefined { } } + // The managed tunnel is served under the app's `basename`, like every other route. A `basePath` the user set wins. + const basePath = GLOBAL_OBJ_WITH_ROUTER_CONTEXT.__reactRouterContext?.basename?.replace(/\/+$/, ''); + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); + applySdkMetadata(options, 'react-router', ['react-router', 'browser']); const client = browserInit(options); diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 1c281cc46adb..98b9f8460144 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,4 +1,4 @@ -import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched } from '@sentry/core'; +import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; import { DEBUG_BUILD } from '../common/debug-build'; // Never matches a request, for when the managed tunnel is off. @@ -7,8 +7,13 @@ const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; /** * A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React * Router has no request hook to answer from. Not meant to be used directly. + * + * @param basename The app's `basename`, which React Router matches the route under but leaves in the request URL. */ -export function createSentryTunnelRoute(): Record { +export function createSentryTunnelRoute(basename?: string): Record { + // `init` runs from `--import` before the server build loads, so the client is usually there already. + setManagedTunnelBasePath(getClient(), basename); + return { id: 'sentry-tunnel', parentId: undefined, @@ -16,12 +21,15 @@ export function createSentryTunnelRoute(): Record { caseSensitive: undefined, // React Router reads the path once, on its first request, and keeps it until the server restarts. get path(): string { - if (!getClient()) { + const client = getClient(); + if (!client) { DEBUG_BUILD && debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); return DISABLED_TUNNEL_PATH; } + setManagedTunnelBasePath(client, basename); + const [path, ...otherPaths] = getTunnelPaths(); if (otherPaths.length) { DEBUG_BUILD && debug.warn(`React Router can only serve one managed tunnel path, so only ${path} is served.`); diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts index c8ed968d5e76..3b9f5a2da2ed 100644 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -4,7 +4,7 @@ const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; /** * Adds the managed tunnel route to the server build. It only serves requests when `Sentry.init` enables the - * managed tunnel. + * managed tunnel. The server build exports the app's `basename`, which the route needs to match the request URL. */ export function makeTunnelRoutePlugin(): Plugin { return { @@ -19,7 +19,7 @@ export function makeTunnelRoutePlugin(): Plugin { return { code: `${code} import { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router'; -routes['sentry-tunnel'] = __sentryCreateTunnelRoute(); +routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined); `, map: null, }; diff --git a/packages/react-router/test/client/sdk.test.ts b/packages/react-router/test/client/sdk.test.ts index c932fec91064..4cb7a16fa9d3 100644 --- a/packages/react-router/test/client/sdk.test.ts +++ b/packages/react-router/test/client/sdk.test.ts @@ -41,6 +41,18 @@ describe('React Router client SDK', () => { expect(browserInit).toHaveBeenCalledWith(expect.objectContaining(expectedMetadata)); }); + it("serves the managed tunnel under the app's `basename`, unless the user set a base path", () => { + vi.stubGlobal('__reactRouterContext', { basename: '/app/' }); + + reactRouterInit({ tunnel: true }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: { basePath: '/app' } })); + + reactRouterInit({ tunnel: { basePath: '/custom' } }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: { basePath: '/custom' } })); + + vi.unstubAllGlobals(); + }); + it('returns client from init', () => { expect(reactRouterInit({})).not.toBeUndefined(); }); diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts index b540cf6c5c7e..d6a7e1fc5b84 100644 --- a/packages/react-router/test/server/tunnelRoute.test.ts +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -54,4 +54,21 @@ describe('createSentryTunnelRoute', () => { expect(await (await route.module.action({ request })).text()).toBe('forwarded'); expect(route.module.loader().status).toBe(405); }); + + it('serves the tunnel under `basename`, which React Router leaves in the request URL', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = createSentryTunnelRoute('/app/') as TunnelRoute; + const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { + method: 'POST', + body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + + // React Router matches the route under `basename` itself, so the path stays relative to it. + expect(route.path).toBe(getTunnelPath(DSN)); + expect(await (await route.module.action({ request })).text()).toBe('forwarded'); + }); }); diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts index 758d0a6f56b6..52e264a9fdea 100644 --- a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -9,7 +9,9 @@ describe('makeTunnelRoutePlugin', () => { it('adds the tunnel route to the SSR server build', () => { const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); - expect(result?.code).toContain("routes['sentry-tunnel'] = __sentryCreateTunnelRoute();"); + expect(result?.code).toContain( + "routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined);", + ); expect(result?.code).toContain("from '@sentry/react-router'"); }); From 9affda3469d19c4f68bb74b64f03644d2056c616 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:52:58 -0400 Subject: [PATCH 71/93] test(react-router): Check the managed tunnel under `react-router dev` too --- .../react-router-8-framework/package.json | 14 +++++++++++++- .../react-router-8-framework/playwright.config.mjs | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 3d9063bb6d0a..db1993aa7905 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -48,7 +48,9 @@ "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel", - "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel" + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel", + "test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -99,6 +101,16 @@ "build-command": "pnpm test:build:tunnel-basepath", "assert-command": "pnpm test:assert:tunnel-basepath", "label": "react-router-8-framework (tunnel-basepath)" + }, + { + "label": "react-router-8-framework (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + }, + { + "label": "react-router-8-framework (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs index 6e93d6fc020e..2fa8a93bb8bc 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs @@ -10,7 +10,7 @@ const startCommands = { const config = getPlaywrightConfig( { - startCommand: startCommands[getRuntime()], + startCommand: process.env.TEST_ENV === 'development' ? 'pnpm dev --port 3030' : startCommands[getRuntime()], port: 3030, }, // Boot Redis before the tests run, outside the webServer startup-timeout window. From f90ba56ecbbdffcc4521bd5cdca7296405b9cb6f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:01:01 -0400 Subject: [PATCH 72/93] fix(react-router): Normalize the tunnel base path with the core util --- packages/react-router/src/client/sdk.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts index 0452bee053ce..5c140ca27f36 100644 --- a/packages/react-router/src/client/sdk.ts +++ b/packages/react-router/src/client/sdk.ts @@ -1,7 +1,14 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata, applyTunnelDefaults, consoleSandbox, GLOBAL_OBJ, setTag } from '@sentry/core'; +import { + applySdkMetadata, + applyTunnelDefaults, + consoleSandbox, + GLOBAL_OBJ, + normalizeTunnelBasePath, + setTag, +} from '@sentry/core'; const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -31,7 +38,7 @@ export function init(options: BrowserOptions): Client | undefined { } // The managed tunnel is served under the app's `basename`, like every other route. A `basePath` the user set wins. - const basePath = GLOBAL_OBJ_WITH_ROUTER_CONTEXT.__reactRouterContext?.basename?.replace(/\/+$/, ''); + const basePath = normalizeTunnelBasePath(GLOBAL_OBJ_WITH_ROUTER_CONTEXT.__reactRouterContext?.basename); options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); applySdkMetadata(options, 'react-router', ['react-router', 'browser']); From 068c11fa40faa6fdbd975c1be2206825c7bcc817 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:25:19 -0400 Subject: [PATCH 73/93] fix(react-router): Serve every managed tunnel path, one route each --- .../react-router-8-framework/instrument.mjs | 5 +- packages/react-router/src/cloudflare/index.ts | 2 +- packages/react-router/src/server/index.ts | 2 +- .../react-router/src/server/tunnelRoute.ts | 68 ++++++++++++------- .../src/vite/makeTunnelRoutePlugin.ts | 6 +- .../test/server/tunnelRoute.test.ts | 19 ++++-- .../test/vite/makeTunnelRoutePlugin.test.ts | 2 +- 7 files changed, 67 insertions(+), 37 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs index 9f2ece437fd1..8a52c804b85a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs @@ -4,5 +4,8 @@ Sentry.init({ dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, - tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server + // The tunnel variants also accept another app's DSN, so the server serves more than one tunnel path. + tunnel: process.env.E2E_TEST_TUNNEL + ? { allowedDsns: ['http://other@localhost:3031/1338'] } + : `http://localhost:3031/`, }); diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index d691eac21ed2..23dc670a803e 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -4,7 +4,7 @@ import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; -export { createSentryTunnelRoute } from '../server/tunnelRoute'; +export { createSentryTunnelRoutes } from '../server/tunnelRoute'; export { createSentryServerInstrumentation, type CreateSentryServerInstrumentationOptions, diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 99fb32c72ad3..aaa6bfcb4e14 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -8,7 +8,7 @@ export { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; -export { createSentryTunnelRoute } from './tunnelRoute'; +export { createSentryTunnelRoutes } from './tunnelRoute'; // React Router instrumentation API support export { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 98b9f8460144..2d1ce3d3744b 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -5,37 +5,59 @@ import { DEBUG_BUILD } from '../common/debug-build'; const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; /** - * A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React - * Router has no request hook to answer from. Not meant to be used directly. + * The resource routes that serve the managed tunnel, one per tunnel path. `sentryReactRouter()` adds them to the + * server build, since React Router has no request hook to answer from and a route matches only one path. Not meant + * to be used directly. * - * @param basename The app's `basename`, which React Router matches the route under but leaves in the request URL. + * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. */ -export function createSentryTunnelRoute(basename?: string): Record { - // `init` runs from `--import` before the server build loads, so the client is usually there already. - setManagedTunnelBasePath(getClient(), basename); +export function createSentryTunnelRoutes(basename?: string): Record> { + // `init` runs from `--import` before the server build loads, so the client and its tunnel paths are usually known. + const client = getClient(); + if (!client) { + return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', () => getLazyTunnelPath(basename)) }; + } + setManagedTunnelBasePath(client, basename); + + return Object.fromEntries( + getTunnelPaths().map((path, index) => { + const id = `sentry-tunnel-${index}`; + + return [id, makeTunnelRoute(id, () => path)]; + }), + ); +} + +/** + * The path of the single route added when `init` runs after the server build loads, e.g. per request on Cloudflare. + * React Router reads it on its first request and keeps it until the server restarts. + */ +function getLazyTunnelPath(basename: string | undefined): string { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); + return DISABLED_TUNNEL_PATH; + } + + setManagedTunnelBasePath(client, basename); + + const [path, ...otherPaths] = getTunnelPaths(); + if (otherPaths.length) { + DEBUG_BUILD && debug.warn(`Sentry.init ran after the server build loaded, so only ${path} is served.`); + } + + return path ?? DISABLED_TUNNEL_PATH; +} + +function makeTunnelRoute(id: string, getPath: () => string): Record { return { - id: 'sentry-tunnel', + id, parentId: undefined, index: undefined, caseSensitive: undefined, - // React Router reads the path once, on its first request, and keeps it until the server restarts. get path(): string { - const client = getClient(); - if (!client) { - DEBUG_BUILD && - debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); - return DISABLED_TUNNEL_PATH; - } - - setManagedTunnelBasePath(client, basename); - - const [path, ...otherPaths] = getTunnelPaths(); - if (otherPaths.length) { - DEBUG_BUILD && debug.warn(`React Router can only serve one managed tunnel path, so only ${path} is served.`); - } - - return path ?? DISABLED_TUNNEL_PATH; + return getPath(); }, module: { action: ({ request }: { request: Request }) => diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts index 3b9f5a2da2ed..4c03163913d9 100644 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -3,7 +3,7 @@ import type { Plugin } from 'vite'; const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; /** - * Adds the managed tunnel route to the server build. It only serves requests when `Sentry.init` enables the + * Adds the managed tunnel routes to the server build. They only serve requests when `Sentry.init` enables the * managed tunnel. The server build exports the app's `basename`, which the route needs to match the request URL. */ export function makeTunnelRoutePlugin(): Plugin { @@ -18,8 +18,8 @@ export function makeTunnelRoutePlugin(): Plugin { return { code: `${code} -import { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router'; -routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined); +import { createSentryTunnelRoutes as __sentryCreateTunnelRoutes } from '@sentry/react-router'; +Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined)); `, map: null, }; diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts index d6a7e1fc5b84..cc27a2b3e385 100644 --- a/packages/react-router/test/server/tunnelRoute.test.ts +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -1,7 +1,7 @@ import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSentryTunnelRoute } from '../../src/server/tunnelRoute'; +import { createSentryTunnelRoutes } from '../../src/server/tunnelRoute'; const DSN = 'https://public@o1.ingest.sentry.io/1337'; @@ -11,12 +11,16 @@ type TunnelRoute = { module: { action: (args: { request: Request }) => Promise | Response; loader: () => Response }; }; +function firstRoute(basename?: string): TunnelRoute { + return Object.values(createSentryTunnelRoutes(basename))[0] as TunnelRoute; +} + function setClient(managedTunnel: object | undefined): void { const options = { dsn: DSN, _managedTunnel: managedTunnel }; getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); } -describe('createSentryTunnelRoute', () => { +describe('createSentryTunnelRoutes', () => { afterEach(() => { getCurrentScope().setClient(undefined); vi.unstubAllGlobals(); @@ -24,19 +28,20 @@ describe('createSentryTunnelRoute', () => { it('is a top-level route at the tunnel path when `init` enabled the managed tunnel', () => { setClient({}); - const route = createSentryTunnelRoute() as TunnelRoute; + const route = firstRoute(); expect(route.parentId).toBeUndefined(); expect(route.path).toBe(getTunnelPath(DSN)); }); - it('serves a path that never matches when the tunnel is off or Sentry is not initialized yet', () => { - const route = createSentryTunnelRoute() as TunnelRoute; + it('adds no route when the tunnel is off, and a path that never matches when Sentry is not initialized yet', () => { + const route = firstRoute(); const pathWithoutClient = route.path; setClient(undefined); expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); expect(route.path).toBe('/__sentry-tunnel-disabled__'); + expect(createSentryTunnelRoutes()).toEqual({}); }); it('forwards tunnel POSTs and rejects other methods', async () => { @@ -45,7 +50,7 @@ describe('createSentryTunnelRoute', () => { 'fetch', vi.fn(() => Promise.resolve(new Response('forwarded'))), ); - const route = createSentryTunnelRoute() as TunnelRoute; + const route = firstRoute(); const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { method: 'POST', body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, @@ -61,7 +66,7 @@ describe('createSentryTunnelRoute', () => { 'fetch', vi.fn(() => Promise.resolve(new Response('forwarded'))), ); - const route = createSentryTunnelRoute('/app/') as TunnelRoute; + const route = firstRoute('/app/'); const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { method: 'POST', body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts index 52e264a9fdea..4982ed40acc1 100644 --- a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -10,7 +10,7 @@ describe('makeTunnelRoutePlugin', () => { const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); expect(result?.code).toContain( - "routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined);", + "Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined));", ); expect(result?.code).toContain("from '@sentry/react-router'"); }); From 1ccbbae39e3188b757a085e578a5e36801f0dfcc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:28 -0400 Subject: [PATCH 74/93] ref(react-router): Report the basename once with setManagedTunnelDefaults --- .../react-router/src/server/tunnelRoute.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 2d1ce3d3744b..f891a06d9e22 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,4 +1,5 @@ -import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { debug, getClient } from '@sentry/core'; +import { getTunnelPaths, handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; // Never matches a request, for when the managed tunnel is off. @@ -12,14 +13,14 @@ const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. */ export function createSentryTunnelRoutes(basename?: string): Record> { + // Runs when the server build loads. Clients created later, e.g. per request on Cloudflare, start with the basename. + setManagedTunnelDefaults({ basePath: basename }); + // `init` runs from `--import` before the server build loads, so the client and its tunnel paths are usually known. - const client = getClient(); - if (!client) { - return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', () => getLazyTunnelPath(basename)) }; + if (!getClient()) { + return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', getLazyTunnelPath) }; } - setManagedTunnelBasePath(client, basename); - return Object.fromEntries( getTunnelPaths().map((path, index) => { const id = `sentry-tunnel-${index}`; @@ -33,15 +34,12 @@ export function createSentryTunnelRoutes(basename?: string): Record Date: Thu, 15 Oct 2026 13:35:14 -0400 Subject: [PATCH 75/93] feat(solidstart): Serve the managed tunnel on SolidStart v1 `withSentry` registers a Nitro v2 plugin that answers tunnel requests from the `request` hook, before the app's middleware. It goes through `server.plugins` because vinxi's dev server ignores plugins that Nitro modules add. --- .../solidstart/app.config.ts | 5 ++ .../test-applications/solidstart/package.json | 13 ++++- .../solidstart/src/entry-client.tsx | 6 +- .../solidstart/src/instrument.server.ts | 2 +- .../solidstart/src/middleware.ts | 9 +++ .../solidstart/tests/tunnel.test.ts | 40 ++++++++++++++ packages/solidstart/rollup.npm.config.mjs | 1 + packages/solidstart/src/config/types.ts | 1 + packages/solidstart/src/config/withSentry.ts | 10 ++++ packages/solidstart/src/nitro/tunnelPlugin.ts | 42 ++++++++++++++ .../solidstart/test/config/withSentry.test.ts | 10 ++++ .../test/nitro/tunnelPlugin.test.ts | 55 +++++++++++++++++++ 12 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts create mode 100644 packages/solidstart/src/nitro/tunnelPlugin.ts create mode 100644 packages/solidstart/test/nitro/tunnelPlugin.test.ts diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 71061cf25d96..671f25fe0fa5 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -4,5 +4,10 @@ import { defineConfig } from '@solidjs/start/config'; export default defineConfig( withSentry({ middleware: './src/middleware.ts', + vite: { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, + }, }), ); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index ac2b8c393432..6ba22eba8161 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -10,7 +10,9 @@ "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:dev": "TEST_ENV=development playwright test db" + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "type": "module", "dependencies": { @@ -36,5 +38,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx index 11087fbb5918..a92e54089aed 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx @@ -3,13 +3,15 @@ import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; import { StartClient, mount } from '@solidjs/start/client'; +declare const __E2E_TEST_TUNNEL__: boolean; + Sentry.init({ // We can't use env variables here, seems like they are stripped // out in production builds. - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', environment: 'qa', // dynamic sampling bias to keep transactions integrations: [solidRouterBrowserTracingIntegration()], - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server // Performance Monitoring tracesSampleRate: 1.0, // Capture 100% of the transactions debug: !!import.meta.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts index 3dd5d8933b7b..1e1ef4a28d61 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts @@ -4,6 +4,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts index 88123a035fb6..9dee5a5d3eb8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts @@ -2,5 +2,14 @@ import { sentryBeforeResponseMiddleware } from '@sentry/solidstart'; import { createMiddleware } from '@solidjs/start/middleware'; export default createMiddleware({ + onRequest: [ + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + event => { + const request = event.request; + if (process.env.E2E_TEST_TUNNEL && request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + }, + ], onBeforeResponse: [sentryBeforeResponseMiddleware()], }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts new file mode 100644 index 000000000000..c07a1ab5f709 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'solidstart'; +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/users/6', + ); + await page.request.get('/users/6'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index d28b29896b10..9b296fb352ae 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -13,6 +13,7 @@ export default makeNPMConfigVariants( 'src/client/solidrouter.ts', 'src/server/solidrouter.ts', 'src/vite/index.ts', + 'src/nitro/tunnelPlugin.ts', ], // prevent this internal code from ending up in our built package (this doesn't happen automatically because // the name doesn't match an SDK dependency) diff --git a/packages/solidstart/src/config/types.ts b/packages/solidstart/src/config/types.ts index 14e814c78195..d9c13ae76ebb 100644 --- a/packages/solidstart/src/config/types.ts +++ b/packages/solidstart/src/config/types.ts @@ -9,6 +9,7 @@ export type RollupConfig = { export type SolidStartInlineConfig = NonNullable[0]>; export type SolidStartInlineServerConfig = { + plugins?: string[]; hooks?: { close?: () => unknown; 'rollup:before'?: (nitro: Nitro) => unknown; diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index e4b0f6d08b49..27354d4f790a 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { debug } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { @@ -116,6 +117,15 @@ export function withSentry( externals, commonJS, modules: [...existingModules, sentryNitroModule], + // Through `server.plugins`, since vinxi's dev server ignores plugins that Nitro modules add. + plugins: [...(server.plugins || []), getTunnelPluginPath()], }, }; } + +function getTunnelPluginPath(): string { + const url = new URL('../nitro/tunnelPlugin.js', import.meta.url); + + // `import.meta.url` is a file URL in the built package, but not in every test environment. + return url.protocol === 'file:' ? fileURLToPath(url) : url.pathname; +} diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts new file mode 100644 index 000000000000..cb84fc9abfeb --- /dev/null +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -0,0 +1,42 @@ +import { Readable } from 'node:stream'; +import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +import type { NitroAppPlugin } from 'nitropack'; + +/** + * Answers managed tunnel requests on SolidStart v1 (Nitro v2). The `request` hook runs before the app's middleware + * and routing. `withSentry` registers it. + */ +export default (nitroApp => { + nitroApp.hooks.hook('request', async event => { + const { req, res } = event.node; + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest(req)) { + return; + } + + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value !== undefined) { + headers.set(name, Array.isArray(value) ? value.join(', ') : value); + } + } + + const tunnelResponse = await handleTunnelRequestIfMatched( + new Request(`http://${req.headers.host}${req.url}`, { + method: req.method, + headers, + body: Readable.toWeb(req) as ReadableStream, + // Required by Node's `fetch` for a streamed body. + duplex: 'half', + } as RequestInit), + ); + if (!tunnelResponse) { + return; + } + + // Writing the response marks the event as handled, so Nitro stops here. + res.statusCode = tunnelResponse.status; + tunnelResponse.headers.forEach((value, name) => res.setHeader(name, value)); + res.end(Buffer.from(await tunnelResponse.arrayBuffer())); + }); +}) satisfies NitroAppPlugin; diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 3afba804811d..74b00a2d2e6b 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -78,6 +78,16 @@ describe('withSentry()', () => { expect(addInstrumentationFileToBuildMock).toHaveBeenCalledWith(nitroOptions); }); + it('registers the managed tunnel plugin through server.plugins and keeps user plugins', () => { + const config = withSentry({ server: { plugins: ['./user-plugin.ts'] } }, {}); + + expect((config?.server as { plugins?: string[] }).plugins).toEqual([ + './user-plugin.ts', + // Vitest serves the source file, the built package the transpiled one. + expect.stringMatching(/\/nitro\/tunnelPlugin\.[jt]s$/), + ]); + }); + it('does not override user-defined hooks in server.hooks', () => { const config = withSentry(solidStartConfig, {}); expect(config?.server.hooks?.['rollup:before']).toBe(userDefinedNitroRollupBeforeHookMock); diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts new file mode 100644 index 000000000000..eac0ed370ce4 --- /dev/null +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -0,0 +1,55 @@ +import { Readable } from 'node:stream'; +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import tunnelPlugin from '../../src/nitro/tunnelPlugin'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +type RequestHook = (event: unknown) => Promise; + +function setupPlugin(): RequestHook { + let requestHook: RequestHook | undefined; + (tunnelPlugin as (nitroApp: unknown) => void)({ + hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && (requestHook = fn) }, + }); + + return requestHook!; +} + +function makeEvent(method: string, url: string, body = ''): { event: unknown; res: { end: ReturnType } } { + const req = Object.assign(Readable.from([Buffer.from(body)]), { method, url, headers: { host: 'localhost' } }); + const res = { statusCode: 200, setHeader: vi.fn(), end: vi.fn() }; + + return { event: { node: { req, res } }, res }; +} + +describe('SolidStart v1 tunnel plugin', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + }); + + it('answers tunnel requests from the request hook when `init` enabled the managed tunnel', async () => { + const options = { dsn: DSN, _managedTunnel: {} }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + const { event, res } = makeEvent('POST', getTunnelPath(DSN)!, envelope); + + await setupPlugin()(event); + + expect(res.end).toHaveBeenCalledWith(Buffer.from('forwarded')); + }); + + it('leaves every other request to the app', async () => { + const { event, res } = makeEvent('POST', '/api/users'); + + await setupPlugin()(event); + + expect(res.end).not.toHaveBeenCalled(); + }); +}); From 3ef6ec459b25c97a3d348510be3ed701e602a58e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:16:37 -0400 Subject: [PATCH 76/93] fix(solidstart): Serve the managed tunnel under `server.baseURL` --- .../solidstart/app.config.ts | 2 + .../test-applications/solidstart/package.json | 9 ++++- .../solidstart/tests/tunnel.test.ts | 12 +++--- packages/solidstart/src/client/sdk.ts | 2 + packages/solidstart/src/common/tunnel.ts | 24 ++++++++++++ packages/solidstart/src/config/withSentry.ts | 23 +++++++++-- packages/solidstart/src/nitro/tunnelPlugin.ts | 20 +++++++++- .../src/vite/sentrySolidStartVite.ts | 24 ++++++++++-- .../solidstart/test/common/tunnel.test.ts | 38 +++++++++++++++++++ .../solidstart/test/config/withSentry.test.ts | 3 ++ .../test/vite/sentrySolidStartVite.test.ts | 5 ++- 11 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 packages/solidstart/src/common/tunnel.ts create mode 100644 packages/solidstart/test/common/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 671f25fe0fa5..7486f6ecf58c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -4,6 +4,8 @@ import { defineConfig } from '@solidjs/start/config'; export default defineConfig( withSentry({ middleware: './src/middleware.ts', + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { server: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), vite: { define: { __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 6ba22eba8161..8fd0ffc0edda 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -12,7 +12,9 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "type": "module", "dependencies": { @@ -45,6 +47,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "solidstart (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "solidstart (tunnel-basepath)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts index c07a1ab5f709..3ff1081fff07 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -4,11 +4,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); const APP_NAME = 'solidstart'; -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); test('answers the managed tunnel before the app middleware', async ({ page }) => { // The variant's auth middleware rejects every other POST. - expect((await page.request.post('/', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( APP_NAME, @@ -18,7 +19,7 @@ test('answers the managed tunnel before the app middleware', async ({ page }) => response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app middleware', async ({ page }) => // would arrive first. const anchorSpanPromise = waitForStreamedSpan( APP_NAME, - span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/users/6', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/users/6`, ); - await page.request.get('/users/6'); + await page.request.get(`${BASE_PATH}/users/6`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), diff --git a/packages/solidstart/src/client/sdk.ts b/packages/solidstart/src/client/sdk.ts index 06ee8f092094..7473f329d11c 100644 --- a/packages/solidstart/src/client/sdk.ts +++ b/packages/solidstart/src/client/sdk.ts @@ -1,6 +1,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/solid'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration, getDefaultIntegrations as getDefaultSolidIntegrations, @@ -19,6 +20,7 @@ export function init(options: BrowserOptions): Client | undefined { ...options, }; + applyTunnelBasePath(opts); applySdkMetadata(opts, 'solidstart', ['solidstart', 'solid']); return initSolidSDK(opts); diff --git a/packages/solidstart/src/common/tunnel.ts b/packages/solidstart/src/common/tunnel.ts new file mode 100644 index 000000000000..1f02ed1f6189 --- /dev/null +++ b/packages/solidstart/src/common/tunnel.ts @@ -0,0 +1,24 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults } from '@sentry/core'; + +/** Set on the server by `withSentry` to the app's `server.baseURL`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_solidstart_base_path'; + +declare const __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: string | undefined; + +/** + * Normalizes SolidStart's `server.baseURL` to the base path the managed tunnel is served under. + */ +export function toTunnelBasePath(baseURL: string | undefined): string { + return (baseURL ?? '').replace(/^\.?\/+|\/+$/g, '').replace(/^(?=.)/, '/'); +} + +/** + * Serves the browser's managed tunnel under the app's `server.baseURL`, which `withSentry` defines at build time. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + const basePath = + typeof __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index 27354d4f790a..b95e2c840b11 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'node:url'; -import { debug } from '@sentry/core'; +import { debug, GLOBAL_OBJ } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { commonJSInteropOptions, @@ -7,6 +7,7 @@ import { sentryOrchestrionPlugin, } from '@sentry/server-utils/orchestrion/rollup'; import type { Nitro, NitroConfig } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL, toTunnelBasePath } from '../common/tunnel'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -47,11 +48,19 @@ export function withSentry( }; const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig; + // Requests reach the tunnel with `server.baseURL` still in front, so both sides serve it under that. + const tunnelBasePath = toTunnelBasePath((server as SolidStartInlineServerConfig & { baseURL?: string }).baseURL); + // vinxi's dev server runs Nitro in this process, so the tunnel plugin can read it from here in dev. + if (tunnelBasePath) { + (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL] = tunnelBasePath; + } + const viteConfig = solidStartConfig.vite; const vite = typeof viteConfig === 'function' - ? (...args: Parameters) => addSentryPluginToVite(viteConfig(...args), sentryPluginOptions) - : addSentryPluginToVite(viteConfig, sentryPluginOptions); + ? (...args: Parameters) => + addSentryPluginToVite(viteConfig(...args), sentryPluginOptions, tunnelBasePath) + : addSentryPluginToVite(viteConfig, sentryPluginOptions, tunnelBasePath); const addBuildTimeInstrumentation = sentryPluginOptions.buildTimeInstrumentation !== false; @@ -62,6 +71,14 @@ export function withSentry( // but at runtime it holds resolved plugin objects we can push onto, so narrow to our own shape. const sentryRollupConfig = rollupConfig as unknown as RollupConfig; + if (tunnelBasePath) { + // The built server runs in another process, so the tunnel plugin reads the base path from the bundle. + sentryRollupConfig.plugins.push({ + name: 'sentry-solidstart-tunnel-base-path', + banner: `globalThis.${TUNNEL_BASE_PATH_GLOBAL} = ${JSON.stringify(tunnelBasePath)};`, + }); + } + if (addBuildTimeInstrumentation) { sentryRollupConfig.plugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: sentryPluginOptions.buildTimeInstrumentation }), diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index cb84fc9abfeb..edb892a5fcdc 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,13 +1,31 @@ import { Readable } from 'node:stream'; -import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +import { + getClient, + GLOBAL_OBJ, + handleTunnelRequestIfMatched, + isTunnelRequest, + setManagedTunnelBasePath, +} from '@sentry/core'; import type { NitroAppPlugin } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; /** * Answers managed tunnel requests on SolidStart v1 (Nitro v2). The `request` hook runs before the app's middleware * and routing. `withSentry` registers it. */ export default (nitroApp => { + // The hook sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. `init` may run + // after this plugin, so this is repeated for the first requests after it too. + const applyBaseURL = (): void => + setManagedTunnelBasePath( + getClient(), + (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], + ); + applyBaseURL(); + nitroApp.hooks.hook('request', async event => { + applyBaseURL(); + const { req, res } = event.node; // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. if (!isTunnelRequest(req)) { diff --git a/packages/solidstart/src/vite/sentrySolidStartVite.ts b/packages/solidstart/src/vite/sentrySolidStartVite.ts index 8bb0a55741d1..61b884c29d2e 100644 --- a/packages/solidstart/src/vite/sentrySolidStartVite.ts +++ b/packages/solidstart/src/vite/sentrySolidStartVite.ts @@ -7,10 +7,22 @@ import type { SentrySolidStartPluginOptions } from './types'; /** * Various Sentry vite plugins to be used for SolidStart. */ -export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {}, viteConfig: UserConfig): Plugin[] => { +export const sentrySolidStartVite = ( + options: SentrySolidStartPluginOptions = {}, + viteConfig: UserConfig, + tunnelBasePath = '', +): Plugin[] => { warnOnRemovedBuildOptions(options, ['unstable_sentryVitePluginOptions']); - const sentryPlugins: Plugin[] = []; + const sentryPlugins: Plugin[] = [ + { + name: 'sentry-solidstart-tunnel-base-path', + // Serves the browser's managed tunnel under `server.baseURL`, like every other route of the app. + config: () => ({ + define: { __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: JSON.stringify(tunnelBasePath) }, + }), + }, + ]; if (options.autoInjectServerSentry !== 'experimental_dynamic-import') { sentryPlugins.push(makeBuildInstrumentationFilePlugin(options)); @@ -31,9 +43,13 @@ export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {} /** * Helper to add the Sentry SolidStart vite plugin to a vite config. */ -export const addSentryPluginToVite = (config: UserConfig = {}, options: SentrySolidStartPluginOptions): UserConfig => { +export const addSentryPluginToVite = ( + config: UserConfig = {}, + options: SentrySolidStartPluginOptions, + tunnelBasePath?: string, +): UserConfig => { const plugins = Array.isArray(config.plugins) ? [...config.plugins] : []; - plugins.unshift(sentrySolidStartVite(options, config)); + plugins.unshift(sentrySolidStartVite(options, config, tunnelBasePath)); return { ...config, diff --git a/packages/solidstart/test/common/tunnel.test.ts b/packages/solidstart/test/common/tunnel.test.ts new file mode 100644 index 000000000000..68c1fd2e6938 --- /dev/null +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyTunnelBasePath, toTunnelBasePath } from '../../src/common/tunnel'; + +describe('toTunnelBasePath', () => { + it('turns `server.baseURL` into a base path, and the root into none', () => { + expect(toTunnelBasePath('/app/')).toBe('/app'); + expect(toTunnelBasePath('app')).toBe('/app'); + expect(toTunnelBasePath('/')).toBe(''); + expect(toTunnelBasePath('./')).toBe(''); + expect(toTunnelBasePath(undefined)).toBe(''); + }); +}); + +describe('applyTunnelBasePath', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('serves the managed tunnel under the defined base path, unless the user set one', () => { + vi.stubGlobal('__SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__', '/app'); + const managed: Parameters[0] = { tunnel: true }; + const own: Parameters[0] = { tunnel: { basePath: '/custom' } }; + + applyTunnelBasePath(managed); + applyTunnelBasePath(own); + + expect(managed.tunnel).toEqual({ basePath: '/app' }); + expect(own.tunnel).toEqual({ basePath: '/custom' }); + }); + + it('leaves the tunnel alone without a base path', () => { + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + }); +}); diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 74b00a2d2e6b..c4c056319b55 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -126,6 +126,7 @@ describe('withSentry()', () => { }); const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -149,6 +150,7 @@ describe('withSentry()', () => { const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -176,6 +178,7 @@ describe('withSentry()', () => { .plugins.flat() .map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', diff --git a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts index 545638f9e3dc..db2382efdc45 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -26,6 +26,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins(); const names = plugins.map(plugin => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -35,7 +36,7 @@ describe('sentrySolidStartVite()', () => { it("returns only build-instrumentation-file plugin if source maps upload isn't enabled", () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: true } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); }); it('returns only build-instrumentation-file plugin if `NODE_ENV` is development', async () => { @@ -44,7 +45,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: false } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); process.env.NODE_ENV = previousEnv; }); From d4c1fa897f0b9908f1d0fb3121be417aaa92c563 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:18:17 -0400 Subject: [PATCH 77/93] test(solidstart): Check the managed tunnel on SolidStart 2 --- .../solidstart-2/package.json | 13 ++++++- .../solidstart-2/server/plugins/sentry.ts | 2 +- .../solidstart-2/src/entry-client.tsx | 7 +++- .../solidstart-2/tests/tunnel.test.ts | 37 +++++++++++++++++++ .../solidstart-2/vite.config.ts | 3 ++ 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 9fd694db92d4..005782087ec5 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -9,7 +9,9 @@ "start": "HOST=localhost PORT=3030 node .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "pnpm test:prod", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "type": "module", "dependencies": { @@ -38,6 +40,13 @@ "node": ">=24" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart-2 (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts index 812abe8255fb..b28336bff817 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts @@ -7,7 +7,7 @@ export default definePlugin(() => { dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx index 11087fbb5918..ce51cefc79f3 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx @@ -3,13 +3,16 @@ import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; import { StartClient, mount } from '@solidjs/start/client'; +declare const __E2E_TEST_TUNNEL__: boolean; + Sentry.init({ // We can't use env variables here, seems like they are stripped // out in production builds. - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', environment: 'qa', // dynamic sampling bias to keep transactions integrations: [solidRouterBrowserTracingIntegration()], - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server // Performance Monitoring tracesSampleRate: 1.0, // Capture 100% of the transactions debug: !!import.meta.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts new file mode 100644 index 000000000000..92b0892e5925 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'solidstart-2'; +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel and drops its server span', async ({ page }) => { + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/users/6', + ); + await page.request.get('/users/6'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts index d3ffd97997a9..28c43c71e6e1 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts @@ -4,6 +4,9 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ solidStart({ appRoot: './src', From 60f8a5efdf8f2d5e10a0e88aa3fc71c7ba8b6b52 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:53:32 -0400 Subject: [PATCH 78/93] test(solidstart): Check the managed tunnel under the SolidStart 2 dev server too --- .../e2e-tests/test-applications/solidstart-2/package.json | 8 +++++++- .../test-applications/solidstart-2/playwright.config.mjs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 005782087ec5..04b5c40c783a 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -11,7 +11,8 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "type": "module", "dependencies": { @@ -46,6 +47,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "solidstart-2 (tunnel)" + }, + { + "label": "solidstart-2 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" } ] } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs index b52ff06a5105..2d40cab81f7e 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs @@ -1,7 +1,7 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: 'pnpm start', + startCommand: process.env.TEST_ENV === 'development' ? 'pnpm exec vite dev --port 3030' : 'pnpm start', port: 3030, }); From 9ca3f64535b9957465cc50cd591140b0240db300 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:09:06 -0400 Subject: [PATCH 79/93] test(solidstart): Check the managed tunnel under `vinxi dev` too --- .../solidstart/instrument.server.mjs | 2 +- .../test-applications/solidstart/package.json | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs index f75de066068a..c543b273ad5d 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs @@ -6,6 +6,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 8fd0ffc0edda..118efe66bc77 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -14,7 +14,9 @@ "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", - "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "type": "module", "dependencies": { @@ -34,8 +36,8 @@ "jsdom": "^24.0.0", "solid-js": "1.9.5", "typescript": "^5.4.5", - "vinxi": "^0.4.0", - "vite": "^5.4.11", + "vinxi": "^0.5.11", + "vite": "^6.4.4", "vite-plugin-solid": "^2.11.6" }, "volta": { @@ -52,6 +54,16 @@ "build-command": "pnpm test:build:tunnel-basepath", "assert-command": "pnpm test:assert:tunnel-basepath", "label": "solidstart (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "solidstart (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "solidstart (tunnel-basepath-dev)" } ] } From c101e43a73a931281e5d37ea8eb4e57f06a78c03 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:01:31 -0400 Subject: [PATCH 80/93] fix(solidstart): Normalize the tunnel base path with the core util --- packages/solidstart/src/common/tunnel.ts | 7 ------- packages/solidstart/src/config/withSentry.ts | 8 +++++--- packages/solidstart/test/common/tunnel.test.ts | 12 +----------- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/solidstart/src/common/tunnel.ts b/packages/solidstart/src/common/tunnel.ts index 1f02ed1f6189..09fbf034b1eb 100644 --- a/packages/solidstart/src/common/tunnel.ts +++ b/packages/solidstart/src/common/tunnel.ts @@ -6,13 +6,6 @@ export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_solidstart_base_path'; declare const __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: string | undefined; -/** - * Normalizes SolidStart's `server.baseURL` to the base path the managed tunnel is served under. - */ -export function toTunnelBasePath(baseURL: string | undefined): string { - return (baseURL ?? '').replace(/^\.?\/+|\/+$/g, '').replace(/^(?=.)/, '/'); -} - /** * Serves the browser's managed tunnel under the app's `server.baseURL`, which `withSentry` defines at build time. * A `basePath` the user set wins. diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index b95e2c840b11..973bf6c78666 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'node:url'; -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { debug, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { commonJSInteropOptions, @@ -7,7 +7,7 @@ import { sentryOrchestrionPlugin, } from '@sentry/server-utils/orchestrion/rollup'; import type { Nitro, NitroConfig } from 'nitropack'; -import { TUNNEL_BASE_PATH_GLOBAL, toTunnelBasePath } from '../common/tunnel'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -49,7 +49,9 @@ export function withSentry( const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig; // Requests reach the tunnel with `server.baseURL` still in front, so both sides serve it under that. - const tunnelBasePath = toTunnelBasePath((server as SolidStartInlineServerConfig & { baseURL?: string }).baseURL); + const tunnelBasePath = normalizeTunnelBasePath( + (server as SolidStartInlineServerConfig & { baseURL?: string }).baseURL, + ); // vinxi's dev server runs Nitro in this process, so the tunnel plugin can read it from here in dev. if (tunnelBasePath) { (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL] = tunnelBasePath; diff --git a/packages/solidstart/test/common/tunnel.test.ts b/packages/solidstart/test/common/tunnel.test.ts index 68c1fd2e6938..a3d7168b0958 100644 --- a/packages/solidstart/test/common/tunnel.test.ts +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -1,15 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { applyTunnelBasePath, toTunnelBasePath } from '../../src/common/tunnel'; - -describe('toTunnelBasePath', () => { - it('turns `server.baseURL` into a base path, and the root into none', () => { - expect(toTunnelBasePath('/app/')).toBe('/app'); - expect(toTunnelBasePath('app')).toBe('/app'); - expect(toTunnelBasePath('/')).toBe(''); - expect(toTunnelBasePath('./')).toBe(''); - expect(toTunnelBasePath(undefined)).toBe(''); - }); -}); +import { applyTunnelBasePath } from '../../src/common/tunnel'; describe('applyTunnelBasePath', () => { afterEach(() => { From deee419a481a825a366a16cbed31460d624a9a28 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:22:44 -0400 Subject: [PATCH 81/93] fix(solidstart): Read the tunnel request with h3 so serverless presets' mock requests work --- packages/solidstart/rollup.npm.config.mjs | 2 +- packages/solidstart/src/nitro/tunnelPlugin.ts | 34 ++++------------- .../test/nitro/tunnelPlugin.test.ts | 38 +++++++++---------- 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index 9b296fb352ae..994c3cbe8fa7 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -18,7 +18,7 @@ export default makeNPMConfigVariants( // prevent this internal code from ending up in our built package (this doesn't happen automatically because // the name doesn't match an SDK dependency) packageSpecificConfig: { - external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'], + external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter', 'h3'], output: { dynamicImportInCjs: true, }, diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index edb892a5fcdc..b6ed40b376d9 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,4 +1,3 @@ -import { Readable } from 'node:stream'; import { getClient, GLOBAL_OBJ, @@ -6,6 +5,7 @@ import { isTunnelRequest, setManagedTunnelBasePath, } from '@sentry/core'; +import { sendWebResponse, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; @@ -26,35 +26,17 @@ export default (nitroApp => { nitroApp.hooks.hook('request', async event => { applyBaseURL(); - const { req, res } = event.node; // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isTunnelRequest(req)) { + if (!isTunnelRequest({ method: event.method, url: event.path })) { return; } - const headers = new Headers(); - for (const [name, value] of Object.entries(req.headers)) { - if (value !== undefined) { - headers.set(name, Array.isArray(value) ? value.join(', ') : value); - } + // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which + // aren't Node streams. + const tunnelResponse = handleTunnelRequestIfMatched(toWebRequest(event)); + if (tunnelResponse) { + // Sending the response marks the event as handled, so Nitro stops here. + await sendWebResponse(event, await tunnelResponse); } - - const tunnelResponse = await handleTunnelRequestIfMatched( - new Request(`http://${req.headers.host}${req.url}`, { - method: req.method, - headers, - body: Readable.toWeb(req) as ReadableStream, - // Required by Node's `fetch` for a streamed body. - duplex: 'half', - } as RequestInit), - ); - if (!tunnelResponse) { - return; - } - - // Writing the response marks the event as handled, so Nitro stops here. - res.statusCode = tunnelResponse.status; - tunnelResponse.headers.forEach((value, name) => res.setHeader(name, value)); - res.end(Buffer.from(await tunnelResponse.arrayBuffer())); }); }) satisfies NitroAppPlugin; diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts index eac0ed370ce4..070c574263d3 100644 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -1,6 +1,7 @@ -import { Readable } from 'node:stream'; import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { createApp, eventHandler, toNodeListener } from 'h3'; +import { fetchNodeRequestHandler } from 'node-mock-http'; import { afterEach, describe, expect, it, vi } from 'vitest'; import tunnelPlugin from '../../src/nitro/tunnelPlugin'; @@ -8,20 +9,19 @@ const DSN = 'https://public@o1.ingest.sentry.io/1337'; type RequestHook = (event: unknown) => Promise; -function setupPlugin(): RequestHook { +/** + * Calls an h3 app running the plugin's `request` hook the way Nitro v2's `localFetch` does, with node-mock-http's + * request, which isn't a Node stream. Serverless presets pass every request through this. + */ +function fetchApp(url: string, init: RequestInit): Promise { let requestHook: RequestHook | undefined; (tunnelPlugin as (nitroApp: unknown) => void)({ hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && (requestHook = fn) }, }); + const app = createApp({ onRequest: event => requestHook!(event) }); + app.use(eventHandler(() => 'app')); - return requestHook!; -} - -function makeEvent(method: string, url: string, body = ''): { event: unknown; res: { end: ReturnType } } { - const req = Object.assign(Readable.from([Buffer.from(body)]), { method, url, headers: { host: 'localhost' } }); - const res = { statusCode: 200, setHeader: vi.fn(), end: vi.fn() }; - - return { event: { node: { req, res } }, res }; + return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); } describe('SolidStart v1 tunnel plugin', () => { @@ -33,23 +33,19 @@ describe('SolidStart v1 tunnel plugin', () => { it('answers tunnel requests from the request hook when `init` enabled the managed tunnel', async () => { const options = { dsn: DSN, _managedTunnel: {} }; getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve(new Response('forwarded'))), - ); + const sentryFetch = vi.fn((_url: string, _init: RequestInit) => Promise.resolve(new Response('forwarded'))); + vi.stubGlobal('fetch', sentryFetch); const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - const { event, res } = makeEvent('POST', getTunnelPath(DSN)!, envelope); - await setupPlugin()(event); + const response = await fetchApp(getTunnelPath(DSN)!, { method: 'POST', body: envelope }); - expect(res.end).toHaveBeenCalledWith(Buffer.from('forwarded')); + expect(await response.text()).toBe('forwarded'); + expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); }); it('leaves every other request to the app', async () => { - const { event, res } = makeEvent('POST', '/api/users'); - - await setupPlugin()(event); + const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); - expect(res.end).not.toHaveBeenCalled(); + expect(await response.text()).toBe('app'); }); }); From 89f6ef6a6808f88b39d938053a11fc9cd77f0bec Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:04:35 -0400 Subject: [PATCH 82/93] fix(solidstart): Serve the tunnel from the first h3 layer so route rules don't run after it --- packages/solidstart/src/nitro/tunnelPlugin.ts | 35 ++++++++++--------- .../test/nitro/tunnelPlugin.test.ts | 33 ++++++++++++----- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index b6ed40b376d9..80f35990c300 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -5,16 +5,16 @@ import { isTunnelRequest, setManagedTunnelBasePath, } from '@sentry/core'; -import { sendWebResponse, toWebRequest } from 'h3'; +import { eventHandler, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; /** - * Answers managed tunnel requests on SolidStart v1 (Nitro v2). The `request` hook runs before the app's middleware - * and routing. `withSentry` registers it. + * Answers managed tunnel requests on SolidStart v1 (Nitro v2), from the first layer of the h3 app. That runs before + * route rules, the app's middleware and routing. `withSentry` registers it. */ export default (nitroApp => { - // The hook sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. `init` may run + // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. `init` may run // after this plugin, so this is repeated for the first requests after it too. const applyBaseURL = (): void => setManagedTunnelBasePath( @@ -23,20 +23,21 @@ export default (nitroApp => { ); applyBaseURL(); - nitroApp.hooks.hook('request', async event => { - applyBaseURL(); + // Not the `request` hook: h3 still runs the first layer after a hook answered, so route rules would act on the + // sent response. + nitroApp.h3App.stack.unshift({ + route: '', + handler: eventHandler(event => { + applyBaseURL(); - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isTunnelRequest({ method: event.method, url: event.path })) { - return; - } + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest({ method: event.method, url: event.path })) { + return undefined; + } - // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which - // aren't Node streams. - const tunnelResponse = handleTunnelRequestIfMatched(toWebRequest(event)); - if (tunnelResponse) { - // Sending the response marks the event as handled, so Nitro stops here. - await sendWebResponse(event, await tunnelResponse); - } + // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which + // aren't Node streams. A returned `Response` is sent and ends the request, `undefined` hands it on. + return handleTunnelRequestIfMatched(toWebRequest(event)); + }), }); }) satisfies NitroAppPlugin; diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts index 070c574263d3..c1eb6307ef8a 100644 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -1,6 +1,6 @@ import type { Client } from '@sentry/core'; import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { createApp, eventHandler, toNodeListener } from 'h3'; +import { createApp, eventHandler, setHeaders, toNodeListener } from 'h3'; import { fetchNodeRequestHandler } from 'node-mock-http'; import { afterEach, describe, expect, it, vi } from 'vitest'; import tunnelPlugin from '../../src/nitro/tunnelPlugin'; @@ -10,27 +10,40 @@ const DSN = 'https://public@o1.ingest.sentry.io/1337'; type RequestHook = (event: unknown) => Promise; /** - * Calls an h3 app running the plugin's `request` hook the way Nitro v2's `localFetch` does, with node-mock-http's - * request, which isn't a Node stream. Serverless presets pass every request through this. + * Calls an h3 app wired like Nitro v2's: the `request` hook runs in `onRequest`, and the route rules layer, here + * with a `headers` rule, comes before the app. Requests come in the way serverless presets pass them through + * `localFetch`, with node-mock-http's request, which isn't a Node stream. */ function fetchApp(url: string, init: RequestInit): Promise { - let requestHook: RequestHook | undefined; - (tunnelPlugin as (nitroApp: unknown) => void)({ - hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && (requestHook = fn) }, + const requestHooks: RequestHook[] = []; + const app = createApp({ + onRequest: async event => { + for (const hook of requestHooks) { + await hook(event); + } + }, + onError: error => onError(error), }); - const app = createApp({ onRequest: event => requestHook!(event) }); + app.use(eventHandler(event => setHeaders(event, { 'x-route-rule': '1' }))); app.use(eventHandler(() => 'app')); + (tunnelPlugin as (nitroApp: unknown) => void)({ + h3App: app, + hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && requestHooks.push(fn) }, + }); return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); } +const onError = vi.fn(); + describe('SolidStart v1 tunnel plugin', () => { afterEach(() => { getCurrentScope().setClient(undefined); vi.unstubAllGlobals(); + vi.clearAllMocks(); }); - it('answers tunnel requests from the request hook when `init` enabled the managed tunnel', async () => { + it('answers tunnel requests before route rules and the app when `init` enabled the managed tunnel', async () => { const options = { dsn: DSN, _managedTunnel: {} }; getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); const sentryFetch = vi.fn((_url: string, _init: RequestInit) => Promise.resolve(new Response('forwarded'))); @@ -41,11 +54,15 @@ describe('SolidStart v1 tunnel plugin', () => { expect(await response.text()).toBe('forwarded'); expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); + // A route rule that ran after the answer would fail to set headers on the sent response. + expect(response.headers.get('x-route-rule')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); }); it('leaves every other request to the app', async () => { const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); expect(await response.text()).toBe('app'); + expect(response.headers.get('x-route-rule')).toBe('1'); }); }); From d6f8d7d8925a65e5aff734871a9ab1ecab86e56a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:33 -0400 Subject: [PATCH 83/93] ref(solidstart): Report the base path once with setManagedTunnelDefaults --- packages/solidstart/src/nitro/tunnelPlugin.ts | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index 80f35990c300..017fd3004e78 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,10 +1,5 @@ -import { - getClient, - GLOBAL_OBJ, - handleTunnelRequestIfMatched, - isTunnelRequest, - setManagedTunnelBasePath, -} from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelDefaults } from '@sentry/core/server'; import { eventHandler, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; @@ -14,22 +9,17 @@ import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; * route rules, the app's middleware and routing. `withSentry` registers it. */ export default (nitroApp => { - // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. `init` may run - // after this plugin, so this is repeated for the first requests after it too. - const applyBaseURL = (): void => - setManagedTunnelBasePath( - getClient(), - (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], - ); - applyBaseURL(); + // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. The server + // bundle's banner sets the global before this plugin runs. + setManagedTunnelDefaults({ + basePath: (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], + }); // Not the `request` hook: h3 still runs the first layer after a hook answered, so route rules would act on the // sent response. nitroApp.h3App.stack.unshift({ route: '', handler: eventHandler(event => { - applyBaseURL(); - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. if (!isTunnelRequest({ method: event.method, url: event.path })) { return undefined; From 65160f0432cd0da26cb24bfe04f78cd60d655e3e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 16:00:42 -0400 Subject: [PATCH 84/93] feat(nextjs): Serve the tunnel from the app with a DSN-derived path --- .../instrumentation-client.ts | 4 +- .../nextjs-16-tunnel/package.json | 14 +- .../nextjs-16-tunnel/sentry.edge.config.ts | 4 +- .../nextjs-16-tunnel/sentry.server.config.ts | 4 +- .../tests/tunnel-dsn-path.test.ts | 59 ++++++ .../nextjs-16-tunnel/worker.ts | 2 +- .../instrumentation-client.ts | 5 +- .../nextjs-pages-dir/instrumentation.ts | 7 +- .../nextjs-pages-dir/next.config.js | 1 + .../nextjs-pages-dir/package.json | 9 +- .../tests/tunnel-dsn-path.test.ts | 61 ++++++ packages/nextjs/package.json | 12 ++ packages/nextjs/rollup.npm.config.mjs | 1 + packages/nextjs/src/client/tunnelRoute.ts | 29 +-- packages/nextjs/src/cloudflare/index.ts | 8 + .../nextjs/src/common/managedTunnelRoute.ts | 61 ++++++ .../src/common/sentryTunnelRouteHandler.ts | 27 +++ .../utils/dropMiddlewareTunnelRequests.ts | 32 ++-- .../src/common/utils/tunnelPathnameMatch.ts | 8 - .../turbopack/constructTurbopackConfig.ts | 10 +- .../turbopack/generateValueInjectionRules.ts | 10 +- packages/nextjs/src/config/types.ts | 19 +- packages/nextjs/src/config/webpack.ts | 10 +- .../src/config/withSentryConfig/buildTime.ts | 12 +- .../getFinalConfigObjectUtils.ts | 28 ++- .../src/config/withSentryConfig/tunnel.ts | 176 +++++++++--------- packages/nextjs/src/edge/index.ts | 11 +- packages/nextjs/src/server/index.ts | 10 + packages/nextjs/src/tunnel/index.ts | 1 + .../nextjs/test/cloudflare/withSentry.test.ts | 18 +- .../test/config/withSentryConfig.test.ts | 7 + .../dropMiddlewareTunnelRequests.test.ts | 42 +++-- .../nextjs/test/utils/tunnelRoute.test.ts | 154 +++++++-------- 33 files changed, 558 insertions(+), 298 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts create mode 100644 packages/nextjs/src/common/managedTunnelRoute.ts create mode 100644 packages/nextjs/src/common/sentryTunnelRouteHandler.ts delete mode 100644 packages/nextjs/src/common/utils/tunnelPathnameMatch.ts create mode 100644 packages/nextjs/src/tunnel/index.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 6709e1cf54a7..2aacb025d0f3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 0b8ace0062d4..b07ffb0f4a6b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -4,15 +4,15 @@ "private": true, "scripts": { "dev": " next dev", - "build": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", - "dev:webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next dev --webpack", - "build-webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", - "start": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next start", + "dev:webpack": "next dev --webpack", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", - "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", @@ -21,7 +21,7 @@ "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", - "test:build:cloudflare": "pnpm install && _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ opennextjs-cloudflare build", + "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build", "start:cloudflare": "opennextjs-cloudflare preview" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts index 4184d564044f..59e2f1e6cb93 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts index 4184d564044f..59e2f1e6cb93 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts new file mode 100644 index 000000000000..753a68993540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getTunnelPath } from '@sentry/core'; + +const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337'); + +test('sends events to the DSN-derived path, served by the app', async ({ page }) => { + const tunnelRequests: string[] = []; + page.on('request', request => { + if (request.method() === 'POST') { + tunnelRequests.push(new URL(request.url()).pathname); + } + }); + + const errorPromise = waitForError('nextjs-16-tunnel', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel error'); + }); + }); + + expect(await errorPromise).toBeDefined(); + expect(tunnelRequests).toContain(TUNNEL_PATH); +}); + +test('does not create server spans for tunnel requests', async ({ page }) => { + const serverSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans('nextjs-16-tunnel', spans => { + serverSpans.push(...spans.filter(span => span.is_segment)); + return false; + }); + + await page.goto('/'); + await page + .evaluate(() => { + throw new Error('Managed tunnel span error'); + }) + .catch(() => undefined); + await page.waitForTimeout(3000); + + const tunnelSpans = serverSpans.filter( + span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), + ); + expect(tunnelSpans).toEqual([]); +}); + +test('answers 404 to an envelope POST on any other path', async ({ request }) => { + const response = await request.post('/notatunnel', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`, + }); + + expect(response.status()).toBe(404); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts index 522d8c460dfd..03b360a7f5e4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts @@ -5,7 +5,7 @@ import handler from './.open-next/worker.js'; export default Sentry.withSentry( () => ({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: 'http://public@localhost:3031/1337', // Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled. tunnel: 'http://localhost:3031/', tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts index 6dd7cce7b75e..5ff9e9d67e61 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts @@ -2,8 +2,9 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy + dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts index 0d67d33d4a94..f85d32b85ca9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts @@ -4,8 +4,11 @@ export function register() { if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy + dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL + ? 'http://public@localhost:3031/1337' + : process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, transportOptions: { // We are doing a lot of events at once in this test diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js index df1b0f5f4147..2961ce220c53 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js @@ -9,6 +9,7 @@ const nextConfig = { module.exports = withSentryConfig(nextConfig, { debug: true, + tunnelRoute: !!process.env.NEXT_PUBLIC_E2E_TUNNEL, sourcemaps: { deleteSourcemapsAfterUpload: true, }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index 022d28be1c02..e77cccfaf8fa 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", "test:build-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", - "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev", + "test:build:tunnel": "pnpm install && NEXT_PUBLIC_E2E_TUNNEL=1 pnpm build", + "test:assert:tunnel": "NEXT_PUBLIC_E2E_TUNNEL=1 pnpm test:prod tunnel-dsn-path" }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", @@ -37,6 +39,11 @@ { "build-command": "pnpm test:build-15", "label": "nextjs-pages-dir (next@15)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nextjs-pages-dir (tunnel)" } ], "optionalVariants": [] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts new file mode 100644 index 000000000000..8b2da41b72a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getTunnelPath } from '@sentry/core'; + +const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337'); + +test.skip(!process.env.NEXT_PUBLIC_E2E_TUNNEL, 'Only runs with `tunnelRoute`'); + +test('sends events to the DSN-derived path, served by the app', async ({ page }) => { + const tunnelRequests: string[] = []; + page.on('request', request => { + if (request.method() === 'POST') { + tunnelRequests.push(new URL(request.url()).pathname); + } + }); + + const errorPromise = waitForError('nextjs-pages-dir', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel error'); + }); + }); + + expect(await errorPromise).toBeDefined(); + expect(tunnelRequests).toContain(TUNNEL_PATH); +}); + +test('does not create server spans for tunnel requests', async ({ page }) => { + const serverSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans('nextjs-pages-dir', spans => { + serverSpans.push(...spans.filter(span => span.is_segment)); + return false; + }); + + await page.goto('/'); + await page + .evaluate(() => { + throw new Error('Managed tunnel span error'); + }) + .catch(() => undefined); + await page.waitForTimeout(3000); + + const tunnelSpans = serverSpans.filter( + span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), + ); + expect(tunnelSpans).toEqual([]); +}); + +test('answers 404 to an envelope POST on any other path', async ({ request }) => { + const response = await request.post('/notatunnel', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`, + }); + + expect(response.status()).toBe(404); +}); diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index b3ce9965ea31..738b0e894e96 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -55,6 +55,11 @@ "types": "./build/types/config/index.d.ts", "default": "./build/cjs/config/index.js" }, + "./tunnel": { + "types": "./build/types/tunnel/index.d.ts", + "import": "./build/esm/tunnel/index.js", + "require": "./build/cjs/tunnel/index.js" + }, "./async-storage-shim": { "import": { "default": "./build/esm/config/templates/requestAsyncStorageShim.js" @@ -73,6 +78,13 @@ "default": "./build/orchestrion-runtime/*.js" } }, + "typesVersions": { + "*": { + "tunnel": [ + "build/types/tunnel/index.d.ts" + ] + } + }, "publishConfig": { "access": "public" }, diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index bef1bd0f92d3..925a49507c0f 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -13,6 +13,7 @@ export default [ 'src/edge/index.ts', 'src/cloudflare/index.ts', 'src/config/index.ts', + 'src/tunnel/index.ts', ], // prevent this internal nextjs code from ending up in our built package (this doesn't happen automatically because diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index 64025fdd5f9d..ba6b187ad7fe 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -1,33 +1,12 @@ -import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; -import { DEBUG_BUILD } from '../common/debug-build'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; -}; +import { getInjectedManagedTunnelOptions } from '../common/managedTunnelRoute'; /** * Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option. */ export function applyTunnelRouteOption(options: BrowserOptions): void { - const tunnelRouteOption = process.env._sentryRewritesTunnelPath || globalWithInjectedValues._sentryRewritesTunnelPath; - if (tunnelRouteOption && options.dsn) { - const dsnComponents = dsnFromString(options.dsn); - if (!dsnComponents) { - return; - } - const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/); - if (sentrySaasDsnMatch) { - const orgId = sentrySaasDsnMatch[1]; - const regionCode = sentrySaasDsnMatch[2]; - let tunnelPath = `${tunnelRouteOption}?o=${orgId}&p=${dsnComponents.projectId}`; - if (regionCode) { - tunnelPath += `&r=${regionCode}`; - } - options.tunnel = tunnelPath; - DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`); - } else { - DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); - } + const tunnelOptions = options.tunnel === undefined && getInjectedManagedTunnelOptions(); + if (tunnelOptions) { + options.tunnel = tunnelOptions; } } diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index c97b7298a143..283d01afcfbb 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -11,6 +11,7 @@ import { setOpenTelemetryContextAsyncContextStrategy, } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; @@ -68,9 +69,16 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; + if (options.tunnel === undefined) { + options.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = + typeof options.tunnel === 'object' && getManagedTunnelSpanFilter(options.tunnel, options.dsn); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, + ...(managedTunnelSpanFilter ? [managedTunnelSpanFilter] : []), // The request spans of Next.js repeat the request span of `withSentry`. An ignored span does not become active, // so the other Next.js spans become children of the request span of `withSentry`, which then gets the route // from their `next.route` and its status from the response. diff --git a/packages/nextjs/src/common/managedTunnelRoute.ts b/packages/nextjs/src/common/managedTunnelRoute.ts new file mode 100644 index 000000000000..2a4a4a3898e2 --- /dev/null +++ b/packages/nextjs/src/common/managedTunnelRoute.ts @@ -0,0 +1,61 @@ +import { HTTP_TARGET } from '@sentry/conventions/attributes'; +import type { Options, TunnelOptions } from '@sentry/core'; +import { escapeStringForRegex, getTunnelPaths, GLOBAL_OBJ } from '@sentry/core'; + +/** + * The route the managed tunnel rewrite points at. The user's `app/sentry-tunnel/route.ts` answers it. + */ +export const MANAGED_TUNNEL_ROUTE = '/sentry-tunnel'; + +/** + * The query parameter that carries the original request path through the rewrite. + */ +export const MANAGED_TUNNEL_PATH_PARAM = 'p'; + +/** + * The tunnel options `withSentryConfig` injects for `tunnelRoute`. + */ +export type ManagedTunnelOptions = Pick & { basePath: string }; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryManagedTunnel?: ManagedTunnelOptions; +}; + +/** + * The tunnel options `withSentryConfig` injected, if `tunnelRoute` is set. + * + * The client bundle reads them from `env`. The server keeps `@sentry/nextjs` external, where `env` isn't inlined, + * so it reads the injected global instead. + */ +export function getInjectedManagedTunnelOptions(): ManagedTunnelOptions | undefined { + if (globalWithInjectedValues._sentryManagedTunnel) { + return globalWithInjectedValues._sentryManagedTunnel; + } + + const injected = process.env._sentryManagedTunnel; + + return injected ? (JSON.parse(injected) as ManagedTunnelOptions) : undefined; +} + +/** + * Drops Next.js' own spans for managed tunnel requests and for the tunnel route. + * + * Next.js sets the request path as `http.target`, not `url.path`, and streamed spans are only matched against + * `ignoreSpans` when they start, so core's `url.path` filter never sees them. + */ +export function getManagedTunnelSpanFilter( + tunnelOptions: TunnelOptions, + dsn: string | undefined, +): NonNullable[number] | undefined { + const paths = getTunnelPaths({ ...tunnelOptions, allowedDsns: tunnelOptions.allowedDsns ?? (dsn ? [dsn] : []) }); + if (!paths.length) { + return undefined; + } + + const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + const routes = [...paths, MANAGED_TUNNEL_ROUTE].map(escapeStringForRegex).join('|'); + + // Next.js still emits the old semantic conventions. + // oxlint-disable-next-line sdk/no-regexp-constructor, typescript/no-deprecated -- escaped config values, see above + return { attributes: { [HTTP_TARGET]: new RegExp(`^${basePath}(?:${routes})/?(?:\\?|$)`) } }; +} diff --git a/packages/nextjs/src/common/sentryTunnelRouteHandler.ts b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts new file mode 100644 index 000000000000..a46e5ef0b506 --- /dev/null +++ b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts @@ -0,0 +1,27 @@ +import { getClient, handleTunnelRequest, isTunnelRequest } from '@sentry/core'; +import { MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from './managedTunnelRoute'; + +/** + * Answers managed tunnel requests in `app/sentry-tunnel/route.ts`: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + */ +export function sentryTunnelRouteHandler(request: Request): Promise { + const tunnelOptions = getClient()?.getOptions()._managedTunnel; + const basePath = tunnelOptions?.basePath?.replace(/\/$/, '') ?? ''; + const url = new URL(request.url); + + // The rewrite matches any envelope POST with a single path segment, so the path must still match the DSN. + const requestedPath = + url.pathname === `${basePath}${MANAGED_TUNNEL_ROUTE}` + ? `${basePath}/${url.searchParams.get(MANAGED_TUNNEL_PATH_PARAM) ?? ''}` + : url.pathname; + + if (!isTunnelRequest({ method: request.method, url: requestedPath })) { + return Promise.resolve(new Response(null, { status: 404 })); + } + + return handleTunnelRequest({ request, allowedDsns: tunnelOptions?.allowedDsns }); +} diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 31064377e4ba..c84e79fd32b3 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,14 +1,10 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import type { RawAttributes } from '@sentry/core'; -import { getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; +import { getClient, isSentryRequestUrl, isTunnelRequest, type Span } from '@sentry/core'; +import { MANAGED_TUNNEL_ROUTE } from '../managedTunnelRoute'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; -import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; -}; - /** * Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest. * This catches: @@ -63,22 +59,26 @@ function isSentryRequestSpan(attrs: RawAttributes>): boo * Checks if a span's HTTP target matches the tunnel route. */ export function isTunnelRouteSpan(spanAttributes: Record): boolean { - const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; - if (!tunnelPath) { - return false; - } - // `http.target` is only read for spans from a user's own OpenTelemetry instrumentation, which // still emits the old semantic conventions; the SDK sets `url.path`. // eslint-disable-next-line typescript/no-deprecated const target = spanAttributes[URL_PATH] ?? spanAttributes[HTTP_TARGET]; + if (typeof target !== 'string') { + return false; + } - if (typeof target === 'string') { - // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") - const pathname = target.split('?')[0] || ''; + // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") + const pathname = target.split('?')[0] || ''; - return isPathnameUnderSentryTunnelRoute(pathname, tunnelPath); + const managedTunnel = getClient()?.getOptions()._managedTunnel; + if (!managedTunnel) { + return false; } - return false; + const basePath = managedTunnel.basePath?.replace(/\/$/, '') ?? ''; + + return ( + pathname === `${basePath}${MANAGED_TUNNEL_ROUTE}` || + isTunnelRequest({ method: 'POST', url: pathname }, managedTunnel) + ); } diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts deleted file mode 100644 index 9f107d33636c..000000000000 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path - * (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel - * `/api/t` must not match `/api/things`. - */ -export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { - return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); -} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 743f4abc35a8..8f87e7750b30 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -18,6 +18,7 @@ import type { TurbopackOptions, } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; +import { getManagedTunnelOptions } from '../withSentryConfig/tunnel'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** @@ -53,17 +54,10 @@ export function constructTurbopackConfig({ ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; - const tunnelPath = - userSentryOptions?.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined; - const valueInjectionRules = generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + managedTunnel: getManagedTunnelOptions(userNextConfig, userSentryOptions?.tunnelRoute), vercelCronsConfig, }); diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..5bc3821dad27 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import type { ManagedTunnelOptions } from '../../common/managedTunnelRoute'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; @@ -10,12 +11,12 @@ import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + managedTunnel, vercelCronsConfig, }: { routeManifest?: RouteManifest; nextJsVersion?: string; - tunnelPath?: string; + managedTunnel?: ManagedTunnelOptions; vercelCronsConfig?: VercelCronsConfig; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; @@ -32,9 +33,8 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } - // Inject tunnel route path for both client and server - if (tunnelPath) { - isomorphicValues._sentryRewritesTunnelPath = tunnelPath; + if (managedTunnel) { + isomorphicValues._sentryManagedTunnel = managedTunnel; } // Inject Vercel crons config for server-side cron auto-instrumentation diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f1d3a8441bcb..b722c08b3ce8 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -266,15 +266,22 @@ export type SentryBuildOptions = Omit< widenClientFileUpload?: boolean; /** - * Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events - * from being sent. This option should be a path (for example: '/error-monitoring'). + * Tunnel Sentry requests through your Next.js server, to circumvent ad-blockers blocking Sentry events from being + * sent. Your server forwards only the envelope, so cookies and auth headers never leave it. * - * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build - * - Pass a string path (e.g., '/monitoring') to use a custom route + * - Pass `true` to derive an ad-blocker-resistant path from the DSN, the same on the server and in the browser + * - Pass a string path (e.g., '/monitoring') to use a fixed route + * + * The tunnel is answered by `app/sentry-tunnel/route.ts`, which the build creates if it is missing: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your - * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events - * can reach Sentry. Matchers have to be static, so use a fixed string route in that case rather than `true`. + * middleware redirects or blocks unauthenticated requests, let requests with the + * `content-type: application/x-sentry-envelope` header through, for example with a `missing` condition in its + * `matcher`. * * NOTE: This feature only works with Next.js 11+ */ diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 833965b69518..6cf567b300df 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -28,6 +28,7 @@ import type { import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; +import { getManagedTunnelOptions } from './withSentryConfig/tunnel'; // Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain // warnings 3 times, we keep track of them here. @@ -719,19 +720,12 @@ function addValueInjectionLoader({ const releaseToInject = releaseName && shouldCreateRelease ? releaseName : undefined; const isomorphicValues = { - // `rewritesTunnel` set by the user in Next.js config - _sentryRewritesTunnelPath: - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined, - // The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead. // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode // Only inject if release creation is not explicitly disabled (to maintain build determinism) SENTRY_RELEASE: releaseToInject && !buildContext.dev ? { id: releaseToInject } : undefined, _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, + _sentryManagedTunnel: getManagedTunnelOptions(userNextConfig, userSentryOptions.tunnelRoute), // This is used to determine version-based dev-symbolication behavior _sentryNextJsVersion: nextJsVersion, }; diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index b93d9ea2fc9c..fbfddce845d4 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { hasOnlyAppRouterPages } from './pagesRouterDetection'; +import { getManagedTunnelOptions } from './tunnel'; /** * Adds Sentry-related build-time variables to `nextConfig.env`. @@ -23,12 +24,7 @@ export function setUpBuildTimeVariables( const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; const basePath = userNextConfig.basePath ?? ''; - const rewritesTunnelPath = - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${basePath}${userSentryOptions.tunnelRoute}` - : undefined; + const managedTunnel = getManagedTunnelOptions(userNextConfig, userSentryOptions.tunnelRoute); const buildTimeVariables: Record = { // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape @@ -49,8 +45,8 @@ export function setUpBuildTimeVariables( buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; } - if (rewritesTunnelPath) { - buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; + if (managedTunnel) { + buildTimeVariables._sentryManagedTunnel = JSON.stringify(managedTunnel); } if (basePath) { diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index c457cb75aaf0..b50ea78c9c85 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -2,6 +2,7 @@ import { debug, isMatchingPattern, parseSemver } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import * as fs from 'fs'; import * as path from 'path'; +import { MANAGED_TUNNEL_ROUTE } from '../../common/managedTunnelRoute'; import type { VercelCronsConfig } from '../../common/types'; import { getBuildLogger } from '../buildLogger'; import { createRouteManifest } from '../manifest/createRouteManifest'; @@ -9,7 +10,7 @@ import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; -import { resolveTunnelRoute, setUpTunnelRewriteRules } from './tunnel'; +import { ensureTunnelRouteFile, getManagedTunnelOptions, setUpTunnelRewriteRules } from './tunnel'; let showedExportModeTunnelWarning = false; let showedExperimentalBuildModeWarning = false; @@ -29,7 +30,7 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin /** * Applies tunnel-route rewrites, if configured. * - * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. + * Note: this mutates `incomingUserNextConfigObject`. */ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, @@ -49,11 +50,26 @@ export function maybeSetUpTunnelRouteRewriteRules( return; } - // Update the global options object to use the resolved value everywhere - const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); - userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; + const tunnelOptions = getManagedTunnelOptions(incomingUserNextConfigObject, userSentryOptions.tunnelRoute); + if (!tunnelOptions) { + return; + } + + try { + const createdRouteFile = ensureTunnelRouteFile(); + if (createdRouteFile) { + getBuildLogger(userSentryOptions.silent).log( + `[@sentry/nextjs] Created ${path.relative(process.cwd(), createdRouteFile)} to answer \`tunnelRoute\`. Commit it with your app.`, + ); + } + } catch (error) { + getBuildLogger(userSentryOptions.silent).warn( + `[@sentry/nextjs] Could not create the tunnel route. Add \`app${MANAGED_TUNNEL_ROUTE}/route.ts\` with \`export { POST } from '@sentry/nextjs/tunnel';\`.`, + error, + ); + } - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + setUpTunnelRewriteRules(incomingUserNextConfigObject, tunnelOptions); } /** diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 78b050a525bd..f7df55a1f57a 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,117 +1,113 @@ -import { _INTERNAL_safeMathRandom } from '@sentry/core'; +import type { ManagedTunnelOptions } from '../../common/managedTunnelRoute'; +import { MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from '../../common/managedTunnelRoute'; +import * as fs from 'fs'; +import * as path from 'path'; import type { NextConfigObject } from '../types'; /** - * Generates a random tunnel route path that's less likely to be blocked by ad-blockers - */ -function generateRandomTunnelRoute(): string { - // Generate a random 8-character alphanumeric string - const randomString = _INTERNAL_safeMathRandom().toString(36).substring(2, 10); - return `/${randomString}`; -} - -/** - * Resolves the tunnel route based on the user's configuration and the environment. - * @param tunnelRoute - The user-provided tunnel route option + * The managed tunnel options for `tunnelRoute`, or `undefined` when the tunnel is off. + * + * `true` derives the path from the DSN at runtime. A string keeps that fixed path. */ -export function resolveTunnelRoute(tunnelRoute: string | true): string { - if (process.env.__SENTRY_TUNNEL_ROUTE__) { - // Reuse cached value from previous build (server/client) - return process.env.__SENTRY_TUNNEL_ROUTE__; +export function getManagedTunnelOptions( + userNextConfig: NextConfigObject, + tunnelRoute: string | boolean | undefined, +): ManagedTunnelOptions | undefined { + if (!tunnelRoute || userNextConfig.output === 'export') { + return undefined; } - const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); - - // Cache for subsequent builds (only during build time) - // Turbopack runs the config twice, so we need a shared context to avoid generating a new tunnel route for each build. - // env works well here - // https://linear.app/getsentry/issue/JS-549/adblock-plus-blocking-requests-to-sentry-and-monitoring-tunnel - if (resolvedTunnelRoute) { - process.env.__SENTRY_TUNNEL_ROUTE__ = resolvedTunnelRoute; - } - - return resolvedTunnelRoute; + return { + basePath: userNextConfig.basePath ?? '', + ...(typeof tunnelRoute === 'string' && { path: tunnelRoute }), + }; } /** - * Injects rewrite rules into the Next.js config provided by the user to tunnel - * requests from the `tunnelPath` to Sentry. + * Rewrites tunnel requests to the app's own tunnel route, in the same process. * - * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. + * With a DSN-derived path the config doesn't know the path, since it doesn't know the DSN. The rewrite matches any + * single-segment envelope POST instead, and the route handler checks the path against the DSN. */ -export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { +export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelOptions: ManagedTunnelOptions): void { const originalRewrites = userNextConfig.rewrites; - // Allow overriding the tunnel destination for E2E tests via environment variable - const destinationOverride = process.env._SENTRY_TUNNEL_DESTINATION_OVERRIDE; - // Make sure destinations are statically defined at build time - const destination = destinationOverride || 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0'; - const destinationWithRegion = - destinationOverride || 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0'; + const tunnelRewrite = tunnelOptions.path + ? { + source: `${tunnelOptions.path}(/?)`, + destination: `${MANAGED_TUNNEL_ROUTE}?${MANAGED_TUNNEL_PATH_PARAM}=${encodeURIComponent(tunnelOptions.path.replace(/^\//, ''))}`, + } + : { + source: '/:sentryTunnelPath', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: `${MANAGED_TUNNEL_ROUTE}?${MANAGED_TUNNEL_PATH_PARAM}=:sentryTunnelPath`, + }; // This function doesn't take any arguments at the time of writing but we future-proof // here in case Next.js ever decides to pass some userNextConfig.rewrites = async (...args: unknown[]) => { - const tunnelRouteRewrite = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - ], - destination, - }; - - const tunnelRouteRewriteWithRegion = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]?r=[region]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'r', // short for region - we keep it short so matching is harder for ad-blockers - value: '(?[a-z]{2})', - }, - ], - destination: destinationWithRegion, - }; - - // Order of these is important, they get applied first to last. - const newRewrites = [tunnelRouteRewriteWithRegion, tunnelRouteRewrite]; - if (typeof originalRewrites !== 'function') { - return newRewrites; + return { beforeFiles: [tunnelRewrite], afterFiles: [], fallback: [] }; } // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it const originalRewritesResult = await originalRewrites(...args); if (Array.isArray(originalRewritesResult)) { - return [...newRewrites, ...originalRewritesResult]; - } else { - return { - ...originalRewritesResult, - beforeFiles: [...newRewrites, ...(originalRewritesResult.beforeFiles || [])], - }; + return { beforeFiles: [tunnelRewrite], afterFiles: originalRewritesResult, fallback: [] }; } + + return { + ...originalRewritesResult, + beforeFiles: [tunnelRewrite, ...(originalRewritesResult.beforeFiles || [])], + }; }; } + +const MANAGED_TUNNEL_ROUTE_FILE_CONTENTS = `// Answers Sentry's tunnel (\`tunnelRoute\`). Created by \`withSentryConfig\`, commit it with your app. +export { POST } from '@sentry/nextjs/tunnel'; +`; + +/** + * Creates the route the tunnel rewrite points at, unless the app already has it. + * + * Next.js only routes files it finds in the app directory, and a route created after it scanned them is missed, + * so the file is written before the build or dev server starts and kept in place. + * + * @returns The path of the file it created, if any. + */ +export function ensureTunnelRouteFile(projectDir: string = process.cwd()): string | undefined { + const appDir = resolveAppDir(projectDir); + const routeDir = path.join(appDir, MANAGED_TUNNEL_ROUTE); + const hasRouteFile = ['ts', 'tsx', 'js', 'jsx', 'mjs'].some(extension => + fs.existsSync(path.join(routeDir, `route.${extension}`)), + ); + if (hasRouteFile) { + return undefined; + } + + const extension = fs.existsSync(path.join(projectDir, 'tsconfig.json')) ? 'ts' : 'js'; + const routeFile = path.join(routeDir, `route.${extension}`); + fs.mkdirSync(routeDir, { recursive: true }); + fs.writeFileSync(routeFile, MANAGED_TUNNEL_ROUTE_FILE_CONTENTS); + + return routeFile; +} + +function resolveAppDir(projectDir: string): string { + const appDir = path.join(projectDir, 'app'); + const srcAppDir = path.join(projectDir, 'src', 'app'); + if (isDirectory(appDir)) { + return appDir; + } + + if (isDirectory(srcAppDir) || isDirectory(path.join(projectDir, 'src'))) { + return srcAppDir; + } + + return appDir; +} + +function isDirectory(dir: string): boolean { + return fs.existsSync(dir) && fs.lstatSync(dir).isDirectory(); +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index b073b4822453..fa5969d31501 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -16,6 +16,7 @@ import { import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { ATTR_NEXT_PAGES_API_ROUTE_TYPE, @@ -66,7 +67,6 @@ export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; - _sentryRewritesTunnelPath?: string; }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ @@ -107,6 +107,15 @@ export function init(options: VercelEdgeOptions = {}): void { ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), }; + if (opts.tunnel === undefined) { + opts.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = typeof opts.tunnel === 'object' && getManagedTunnelSpanFilter(opts.tunnel, opts.dsn); + if (managedTunnelSpanFilter) { + opts.ignoreSpans = [...(opts.ignoreSpans || []), managedTunnelSpanFilter]; + } + const nextjsIgnoreSpans: NonNullable = [ // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 6dea488a7d9d..9a949548f645 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -13,6 +13,7 @@ import { import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { isBuild } from '../common/utils/isBuild'; @@ -155,6 +156,15 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; + if (opts.tunnel === undefined) { + opts.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = typeof opts.tunnel === 'object' && getManagedTunnelSpanFilter(opts.tunnel, opts.dsn); + if (managedTunnelSpanFilter) { + opts.ignoreSpans = [...(opts.ignoreSpans || []), managedTunnelSpanFilter]; + } + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; if (DEBUG_BUILD && opts.debug) { diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts new file mode 100644 index 000000000000..3589068f33a5 --- /dev/null +++ b/packages/nextjs/src/tunnel/index.ts @@ -0,0 +1 @@ +export { sentryTunnelRouteHandler as POST } from '../common/sentryTunnelRouteHandler'; diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index ff3395f62b4e..9a85d91fc282 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -28,7 +28,7 @@ describe('withSentry', () => { getMainCarrier().__SENTRY__ = undefined; (GLOBAL_OBJ as typeof GLOBAL_OBJ & { __SENTRY_CLOUDFLARE_CLIENT__?: unknown }).__SENTRY_CLOUDFLARE_CLIENT__ = undefined; - delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath; + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel; delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; vi.restoreAllMocks(); trace.disable(); @@ -186,8 +186,10 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel = { + basePath: '', + path: '/monitoring', + }; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { fetch: (request: Request) => { @@ -197,7 +199,7 @@ describe('withSentry', () => { }, }); - await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + await handler.fetch?.(new Request('https://example.com/monitoring', { method: 'POST' }) as never, {}, { waitUntil: vi.fn(), passThroughOnException: vi.fn(), props: {}, @@ -212,8 +214,10 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route when a `tracesSampler` samples every span', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel = { + basePath: '', + path: '/monitoring', + }; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampler: () => 1 }), { fetch: (request: Request) => { @@ -223,7 +227,7 @@ describe('withSentry', () => { }, }); - await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + await handler.fetch?.(new Request('https://example.com/monitoring', { method: 'POST' }) as never, {}, { waitUntil: vi.fn(), passThroughOnException: vi.fn(), props: {}, diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index cc749732eb35..c31e139a2777 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -6,10 +6,17 @@ import { } from '../../src/config/diagnosticsChannelInjection'; import type { SentryBuildOptions } from '../../src/config/types'; import * as util from '../../src/config/util'; +import type * as tunnelModule from '../../src/config/withSentryConfig/tunnel'; import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from '../../src/config/withSentryConfig'; import { defaultRuntimePhase, defaultsObject, exportedNextConfig, userNextConfig } from './fixtures'; import { materializeFinalNextConfig } from './testUtils'; +// `tunnelRoute` creates the app's tunnel route file, which must not land in this package. +vi.mock('../../src/config/withSentryConfig/tunnel', async importOriginal => ({ + ...(await importOriginal()), + ensureTunnelRouteFile: vi.fn(), +})); + // Build-time instrumentation is on by default, so the bundle-safe packages are deliberately dropped // from the externals defaults (the loader transforms them) and the orchestrion runtime is added. // Asserted by exact equality, so a regression that left them external fails here. diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index a635d5d65be5..7a887a57fa10 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -2,15 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; -const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; -}; +let managedTunnel: { basePath: string; path?: string } | undefined; vi.mock('@sentry/core', async requireActual => { return { ...(await requireActual()), getClient: () => ({ - getOptions: () => ({}), + getOptions: () => ({ _managedTunnel: managedTunnel }), }), }; }); @@ -26,25 +24,25 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: } beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + managedTunnel = undefined; }); describe('dropMiddlewareTunnelRequests', () => { describe('BaseServer.handleRequest spans', () => { it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'BaseServer.handleRequest', - 'http.target': '/monitoring?o=123&p=456', + 'http.target': '/monitoring?o=123', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); }); it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -56,7 +54,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -67,8 +65,20 @@ describe('dropMiddlewareTunnelRequests', () => { expect(span.setAttribute).not.toHaveBeenCalled(); }); + it('marks the span of the tunnel route the rewrite points at', () => { + managedTunnel = { basePath: '' }; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/sentry-tunnel?p=abc123', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -91,12 +101,12 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring'; + managedTunnel = { basePath: '/basepath', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'BaseServer.handleRequest', - 'http.target': '/basepath/monitoring?o=123&p=456', + 'http.target': '/basepath/monitoring', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); @@ -105,12 +115,12 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Middleware.execute spans', () => { it('marks middleware span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'Middleware.execute', - 'http.target': '/monitoring?o=123&p=456', + 'http.target': '/monitoring?o=123', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); @@ -119,7 +129,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('unrelated spans', () => { it('does not process spans without matching span type or origin', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -138,7 +148,7 @@ describe('dropMiddlewareTunnelRequests', () => { getOptions: () => ({ enableOpenTelemetrySetup: false }), } as any); - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 8382e66ca7d4..210147b5767f 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -1,120 +1,124 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { BrowserOptions } from '@sentry/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; +import { + ensureTunnelRouteFile, + getManagedTunnelOptions, + setUpTunnelRewriteRules, +} from '../../src/config/withSentryConfig/tunnel'; +import type { NextConfigObject } from '../../src/config/types'; const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; + _sentryManagedTunnel?: unknown; }; -beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; +afterEach(() => { + delete globalWithInjectedValues._sentryManagedTunnel; }); describe('applyTunnelRouteOption()', () => { - it('Correctly applies `tunnelRoute` option when conditions are met', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('applies the injected tunnel options', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '/base' }; + const options: BrowserOptions = { dsn: 'https://public@o1.ingest.sentry.io/1' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333'); + expect(options.tunnel).toEqual({ basePath: '/base' }); }); - it("Doesn't apply `tunnelRoute` when DSN is missing", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - // no dsn - } as BrowserOptions; + it('keeps a `tunnel` the user set', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '' }; + const options: BrowserOptions = { tunnel: '/my-tunnel' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBeUndefined(); + expect(options.tunnel).toBe('/my-tunnel'); }); - it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { - // Avoid polluting the test output with error messages - const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'invalidDsn', - } as BrowserOptions; + it('leaves `tunnel` unset without `tunnelRoute`', () => { + const options: BrowserOptions = {}; applyTunnelRouteOption(options); expect(options.tunnel).toBeUndefined(); - - mockConsoleError.mockRestore(); }); +}); - it("Doesn't apply `tunnelRoute` option when `tunnelRoute` option wasn't injected", () => { - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); +describe('getManagedTunnelOptions()', () => { + it('derives the path from the DSN for `true` and keeps a string path', () => { + expect(getManagedTunnelOptions({ basePath: '/base' }, true)).toEqual({ basePath: '/base' }); + expect(getManagedTunnelOptions({}, '/monitoring')).toEqual({ basePath: '', path: '/monitoring' }); }); - it("Doesn't `tunnelRoute` option when DSN is not a SaaS DSN", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@example.com/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); + it('is off without `tunnelRoute` and for static exports', () => { + expect(getManagedTunnelOptions({}, undefined)).toBeUndefined(); + expect(getManagedTunnelOptions({}, false)).toBeUndefined(); + expect(getManagedTunnelOptions({ output: 'export' }, true)).toBeUndefined(); }); +}); - it('Correctly applies `tunnelRoute` option to region DSNs', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333', - } as BrowserOptions; +describe('setUpTunnelRewriteRules()', () => { + it('rewrites envelope POSTs on a single segment to the tunnel route for a DSN-derived path', async () => { + const config: NextConfigObject = {}; + setUpTunnelRewriteRules(config, { basePath: '' }); + + expect(await config.rewrites!()).toEqual({ + beforeFiles: [ + { + source: '/:sentryTunnelPath', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: '/sentry-tunnel?p=:sentryTunnelPath', + }, + ], + afterFiles: [], + fallback: [], + }); + }); - applyTunnelRouteOption(options); + it('rewrites a fixed path and keeps the user rewrites', async () => { + const userRewrite = { source: '/old', destination: '/new' }; + const config: NextConfigObject = { rewrites: async () => [userRewrite] }; + setUpTunnelRewriteRules(config, { basePath: '', path: '/api/monitor' }); - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333&r=us'); + expect(await config.rewrites!()).toEqual({ + beforeFiles: [{ source: '/api/monitor(/?)', destination: '/sentry-tunnel?p=api%2Fmonitor' }], + afterFiles: [userRewrite], + fallback: [], + }); }); }); -describe('Random tunnel route generation', () => { - it('Works when tunnelRoute is true and generates random-looking paths', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/abc123def'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; +describe('ensureTunnelRouteFile()', () => { + const projectDirs: string[] = []; - applyTunnelRouteOption(options); + function makeProjectDir(...dirs: string[]): string { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-tunnel-')); + projectDirs.push(projectDir); + dirs.forEach(dir => fs.mkdirSync(path.join(projectDir, dir), { recursive: true })); + return projectDir; + } - expect(options.tunnel).toBe('/abc123def?o=2222222&p=3333333'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333$/); + afterEach(() => { + projectDirs.splice(0).forEach(dir => fs.rmSync(dir, { recursive: true, force: true })); }); - it('Works with region DSNs when tunnelRoute is true', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/x7h9k2m'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.eu.sentry.io/3333333', - } as BrowserOptions; + it('creates the route in `app` and leaves an existing one alone', () => { + const projectDir = makeProjectDir('app'); + fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), '{}'); - applyTunnelRouteOption(options); + const routeFile = ensureTunnelRouteFile(projectDir); - expect(options.tunnel).toBe('/x7h9k2m?o=2222222&p=3333333&r=eu'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333&r=eu$/); + expect(routeFile).toBe(path.join(projectDir, 'app', 'sentry-tunnel', 'route.ts')); + expect(fs.readFileSync(routeFile!, 'utf8')).toContain("export { POST } from '@sentry/nextjs/tunnel';"); + expect(ensureTunnelRouteFile(projectDir)).toBeUndefined(); }); - it('Does not apply tunnel when tunnelRoute is false', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('uses `src/app` in a `src` layout and JavaScript without a tsconfig', () => { + const projectDir = makeProjectDir('src/pages'); - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'src', 'app', 'sentry-tunnel', 'route.js')); }); }); From e0d111163abbac15f12f6541ce21e68d082f2c93 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 16:25:04 -0400 Subject: [PATCH 85/93] fix(nextjs): Read the injected tunnel options when the client has none --- .../nextjs/src/common/sentryTunnelRouteHandler.ts | 11 +++++++---- .../src/common/utils/dropMiddlewareTunnelRequests.ts | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/nextjs/src/common/sentryTunnelRouteHandler.ts b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts index a46e5ef0b506..b541eed68452 100644 --- a/packages/nextjs/src/common/sentryTunnelRouteHandler.ts +++ b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts @@ -1,5 +1,6 @@ +import type { TunnelOptions } from '@sentry/core'; import { getClient, handleTunnelRequest, isTunnelRequest } from '@sentry/core'; -import { MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from './managedTunnelRoute'; +import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from './managedTunnelRoute'; /** * Answers managed tunnel requests in `app/sentry-tunnel/route.ts`: @@ -9,7 +10,9 @@ import { MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from './managedTunnel * ``` */ export function sentryTunnelRouteHandler(request: Request): Promise { - const tunnelOptions = getClient()?.getOptions()._managedTunnel; + // On OpenNext the client is set up before the injected options are, so they are read here as well. + const tunnelOptions: TunnelOptions | undefined = + getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); const basePath = tunnelOptions?.basePath?.replace(/\/$/, '') ?? ''; const url = new URL(request.url); @@ -19,9 +22,9 @@ export function sentryTunnelRouteHandler(request: Request): Promise { ? `${basePath}/${url.searchParams.get(MANAGED_TUNNEL_PATH_PARAM) ?? ''}` : url.pathname; - if (!isTunnelRequest({ method: request.method, url: requestedPath })) { + if (!tunnelOptions || !isTunnelRequest({ method: request.method, url: requestedPath }, tunnelOptions)) { return Promise.resolve(new Response(null, { status: 404 })); } - return handleTunnelRequest({ request, allowedDsns: tunnelOptions?.allowedDsns }); + return handleTunnelRequest({ request, allowedDsns: tunnelOptions.allowedDsns }); } diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index c84e79fd32b3..3d92df95f9e7 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,7 +1,7 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import type { RawAttributes } from '@sentry/core'; import { getClient, isSentryRequestUrl, isTunnelRequest, type Span } from '@sentry/core'; -import { MANAGED_TUNNEL_ROUTE } from '../managedTunnelRoute'; +import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_ROUTE } from '../managedTunnelRoute'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; @@ -70,7 +70,7 @@ export function isTunnelRouteSpan(spanAttributes: Record): bool // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") const pathname = target.split('?')[0] || ''; - const managedTunnel = getClient()?.getOptions()._managedTunnel; + const managedTunnel = getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); if (!managedTunnel) { return false; } From 9ad5e7df69a863e374403ce20279776510daf972 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:50:43 -0400 Subject: [PATCH 86/93] test(nextjs): Wait for a later span instead of a timeout in the tunnel span test --- .../tests/tunnel-dsn-path.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts index 753a68993540..bf2488210666 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; import type { SerializedStreamedSpan } from '@sentry/core'; import { getTunnelPath } from '@sentry/core'; @@ -35,13 +35,25 @@ test('does not create server spans for tunnel requests', async ({ page }) => { return false; }); + const errorPromise = waitForError('nextjs-16-tunnel', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel span error'; + }); + await page.goto('/'); - await page - .evaluate(() => { + await page.evaluate(() => { + setTimeout(() => { throw new Error('Managed tunnel span error'); - }) - .catch(() => undefined); - await page.waitForTimeout(3000); + }); + }); + await errorPromise; + + // Spans flush in order, so a span for the tunnel request would arrive before the one for this request. + const anchorSpanPromise = waitForStreamedSpan( + 'nextjs-16-tunnel', + span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /', + ); + await page.request.get('/'); + await anchorSpanPromise; const tunnelSpans = serverSpans.filter( span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), From 13bb2792431b70c1d9a452789a2b62faa0b9163b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 22:21:14 -0400 Subject: [PATCH 87/93] fix(nextjs): Write the tunnel route into the root app dir when the root has pages --- .../src/config/withSentryConfig/tunnel.ts | 12 ++--- .../config/withSentryConfig/tunnel.test.ts | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 packages/nextjs/test/config/withSentryConfig/tunnel.test.ts diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index f7df55a1f57a..2fdda95aeb00 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -94,18 +94,16 @@ export function ensureTunnelRouteFile(projectDir: string = process.cwd()): strin return routeFile; } +/** + * Next.js only reads `src/app` and `src/pages` when neither `app` nor `pages` exists in the project root. + */ function resolveAppDir(projectDir: string): string { const appDir = path.join(projectDir, 'app'); - const srcAppDir = path.join(projectDir, 'src', 'app'); - if (isDirectory(appDir)) { + if (isDirectory(appDir) || isDirectory(path.join(projectDir, 'pages'))) { return appDir; } - if (isDirectory(srcAppDir) || isDirectory(path.join(projectDir, 'src'))) { - return srcAppDir; - } - - return appDir; + return isDirectory(path.join(projectDir, 'src')) ? path.join(projectDir, 'src', 'app') : appDir; } function isDirectory(dir: string): boolean { diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts new file mode 100644 index 000000000000..774d66da9896 --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ensureTunnelRouteFile } from '../../../src/config/withSentryConfig/tunnel'; + +describe('ensureTunnelRouteFile', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-tunnel-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + function mkdirs(...dirs: string[]): void { + dirs.forEach(dir => fs.mkdirSync(path.join(projectDir, dir), { recursive: true })); + } + + it.each([ + ['a root app dir', ['app', 'src/app'], 'app/sentry-tunnel/route.js'], + ['a root pages dir next to an unrelated src dir', ['pages', 'src/utils'], 'app/sentry-tunnel/route.js'], + ['a src app dir', ['src/app'], 'src/app/sentry-tunnel/route.js'], + ['a src pages dir', ['src/pages'], 'src/app/sentry-tunnel/route.js'], + ['no app or pages dir', [], 'app/sentry-tunnel/route.js'], + ])('writes the route into the app dir Next.js reads, given %s', (_, dirs, expected) => { + mkdirs(...dirs); + + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, expected)); + }); + + it('writes a TypeScript route when the project has a tsconfig', () => { + fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), '{}'); + + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'app/sentry-tunnel/route.ts')); + }); + + it('keeps an existing route', () => { + mkdirs('app/sentry-tunnel'); + fs.writeFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), ''); + + expect(ensureTunnelRouteFile(projectDir)).toBeUndefined(); + }); +}); From 43c4a5d287ac349e50c250ddf7da2a894a39be44 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 22:21:14 -0400 Subject: [PATCH 88/93] test(nextjs): Wait for a later span instead of a timeout in the pages tunnel test --- .../tests/tunnel-dsn-path.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts index 8b2da41b72a4..d4b4aec4b227 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; import type { SerializedStreamedSpan } from '@sentry/core'; import { getTunnelPath } from '@sentry/core'; @@ -37,13 +37,25 @@ test('does not create server spans for tunnel requests', async ({ page }) => { return false; }); + const errorPromise = waitForError('nextjs-pages-dir', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel span error'; + }); + await page.goto('/'); - await page - .evaluate(() => { + await page.evaluate(() => { + setTimeout(() => { throw new Error('Managed tunnel span error'); - }) - .catch(() => undefined); - await page.waitForTimeout(3000); + }); + }); + await errorPromise; + + // Spans flush in order, so a span for the tunnel request would arrive before the one for this request. + const anchorSpanPromise = waitForStreamedSpan( + 'nextjs-pages-dir', + span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /', + ); + await page.request.get('/'); + await anchorSpanPromise; const tunnelSpans = serverSpans.filter( span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), From bf77f913494857399d18bd3c78f18365211815b2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 23:43:53 -0400 Subject: [PATCH 89/93] fix(nextjs): Point middleware at the tunnel path instead of the envelope content type --- packages/nextjs/src/common/index.ts | 1 + packages/nextjs/src/config/types.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index b9a652522349..3e655671c97f 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -12,3 +12,4 @@ export { wrapPageComponentWithSentry } from './pages-router-instrumentation/wrap export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; export { withServerActionInstrumentation } from './withServerActionInstrumentation'; export { captureRequestError } from './captureRequestError'; +export { getTunnelPath } from '@sentry/core'; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index b722c08b3ce8..2b3ec8d7e991 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -279,9 +279,9 @@ export type SentryBuildOptions = Omit< * ``` * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your - * middleware redirects or blocks unauthenticated requests, let requests with the - * `content-type: application/x-sentry-envelope` header through, for example with a `missing` condition in its - * `matcher`. + * middleware redirects or blocks unauthenticated requests, let the tunnel path through: the string you set, or + * `getTunnelPath(dsn)` from `@sentry/nextjs` for `true`. Don't exempt requests by their `content-type` header, + * since any client can set it. * * NOTE: This feature only works with Next.js 11+ */ From 1efc0176088cb2ddf9b992b44ca2903fe4fcc188 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 23:50:22 -0400 Subject: [PATCH 90/93] feat(nextjs): Accept allowed DSNs in `tunnelRoute` for a browser DSN that differs from the server's --- .../nextjs/src/common/managedTunnelRoute.ts | 2 +- packages/nextjs/src/config/types.ts | 5 +- .../src/config/withSentryConfig/tunnel.ts | 10 ++- .../config/withSentryConfig/tunnel.test.ts | 46 -------------- .../nextjs/test/utils/tunnelRoute.test.ts | 61 ++++++++++++++++++- 5 files changed, 72 insertions(+), 52 deletions(-) delete mode 100644 packages/nextjs/test/config/withSentryConfig/tunnel.test.ts diff --git a/packages/nextjs/src/common/managedTunnelRoute.ts b/packages/nextjs/src/common/managedTunnelRoute.ts index 2a4a4a3898e2..a67fe14dbdf9 100644 --- a/packages/nextjs/src/common/managedTunnelRoute.ts +++ b/packages/nextjs/src/common/managedTunnelRoute.ts @@ -15,7 +15,7 @@ export const MANAGED_TUNNEL_PATH_PARAM = 'p'; /** * The tunnel options `withSentryConfig` injects for `tunnelRoute`. */ -export type ManagedTunnelOptions = Pick & { basePath: string }; +export type ManagedTunnelOptions = Pick & { basePath: string }; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: ManagedTunnelOptions; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 2b3ec8d7e991..f064ffdd0395 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -4,6 +4,7 @@ import type { ModuleMetadata, ModuleMetadataCallback, ReactComponentAnnotationOptions, + TunnelOptions, } from '@sentry/core'; // The first argument to `withSentryConfig` (which is the user's next config). @@ -271,6 +272,8 @@ export type SentryBuildOptions = Omit< * * - Pass `true` to derive an ad-blocker-resistant path from the DSN, the same on the server and in the browser * - Pass a string path (e.g., '/monitoring') to use a fixed route + * - Pass `{ path, allowedDsns }` to also forward envelopes for other DSNs, e.g. when the browser uses a different + * DSN than the server. Without `allowedDsns`, only the server's own DSN is forwarded. * * The tunnel is answered by `app/sentry-tunnel/route.ts`, which the build creates if it is missing: * @@ -285,7 +288,7 @@ export type SentryBuildOptions = Omit< * * NOTE: This feature only works with Next.js 11+ */ - tunnelRoute?: string | boolean; + tunnelRoute?: string | boolean | Pick; /** * Suppress the warning about the `onRouterTransitionStart` hook. diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 2fdda95aeb00..6840aca84611 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -2,7 +2,7 @@ import type { ManagedTunnelOptions } from '../../common/managedTunnelRoute'; import { MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from '../../common/managedTunnelRoute'; import * as fs from 'fs'; import * as path from 'path'; -import type { NextConfigObject } from '../types'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; /** * The managed tunnel options for `tunnelRoute`, or `undefined` when the tunnel is off. @@ -11,15 +11,19 @@ import type { NextConfigObject } from '../types'; */ export function getManagedTunnelOptions( userNextConfig: NextConfigObject, - tunnelRoute: string | boolean | undefined, + tunnelRoute: SentryBuildOptions['tunnelRoute'], ): ManagedTunnelOptions | undefined { if (!tunnelRoute || userNextConfig.output === 'export') { return undefined; } + const { path, allowedDsns } = + typeof tunnelRoute === 'object' ? tunnelRoute : { path: typeof tunnelRoute === 'string' ? tunnelRoute : undefined }; + return { basePath: userNextConfig.basePath ?? '', - ...(typeof tunnelRoute === 'string' && { path: tunnelRoute }), + ...(path && { path }), + ...(allowedDsns && { allowedDsns }), }; } diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts deleted file mode 100644 index 774d66da9896..000000000000 --- a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ensureTunnelRouteFile } from '../../../src/config/withSentryConfig/tunnel'; - -describe('ensureTunnelRouteFile', () => { - let projectDir: string; - - beforeEach(() => { - projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-tunnel-')); - }); - - afterEach(() => { - fs.rmSync(projectDir, { recursive: true, force: true }); - }); - - function mkdirs(...dirs: string[]): void { - dirs.forEach(dir => fs.mkdirSync(path.join(projectDir, dir), { recursive: true })); - } - - it.each([ - ['a root app dir', ['app', 'src/app'], 'app/sentry-tunnel/route.js'], - ['a root pages dir next to an unrelated src dir', ['pages', 'src/utils'], 'app/sentry-tunnel/route.js'], - ['a src app dir', ['src/app'], 'src/app/sentry-tunnel/route.js'], - ['a src pages dir', ['src/pages'], 'src/app/sentry-tunnel/route.js'], - ['no app or pages dir', [], 'app/sentry-tunnel/route.js'], - ])('writes the route into the app dir Next.js reads, given %s', (_, dirs, expected) => { - mkdirs(...dirs); - - expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, expected)); - }); - - it('writes a TypeScript route when the project has a tsconfig', () => { - fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), '{}'); - - expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'app/sentry-tunnel/route.ts')); - }); - - it('keeps an existing route', () => { - mkdirs('app/sentry-tunnel'); - fs.writeFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), ''); - - expect(ensureTunnelRouteFile(projectDir)).toBeUndefined(); - }); -}); diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 210147b5767f..2e978c8bb9c4 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -2,8 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { BrowserOptions } from '@sentry/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { getTunnelPath } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; +import { sentryTunnelRouteHandler } from '../../src/common/sentryTunnelRouteHandler'; import { ensureTunnelRouteFile, getManagedTunnelOptions, @@ -53,6 +55,17 @@ describe('getManagedTunnelOptions()', () => { expect(getManagedTunnelOptions({}, '/monitoring')).toEqual({ basePath: '', path: '/monitoring' }); }); + it('keeps the path and allowed DSNs of an options object', () => { + const allowedDsns = ['https://browser@o1.ingest.sentry.io/1']; + + expect(getManagedTunnelOptions({}, { allowedDsns })).toEqual({ basePath: '', allowedDsns }); + expect(getManagedTunnelOptions({}, { path: '/monitoring', allowedDsns })).toEqual({ + basePath: '', + path: '/monitoring', + allowedDsns, + }); + }); + it('is off without `tunnelRoute` and for static exports', () => { expect(getManagedTunnelOptions({}, undefined)).toBeUndefined(); expect(getManagedTunnelOptions({}, false)).toBeUndefined(); @@ -60,6 +73,46 @@ describe('getManagedTunnelOptions()', () => { }); }); +describe('sentryTunnelRouteHandler()', () => { + const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/1'; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function tunnelRequest(dsn: string): Request { + return new Request(`http://localhost/sentry-tunnel?p=${getTunnelPath(dsn)!.slice(1)}`, { + method: 'POST', + body: `${JSON.stringify({ dsn })}\n`, + }); + } + + it('forwards envelopes for the allowed DSNs of `tunnelRoute`', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchMock); + globalWithInjectedValues._sentryManagedTunnel = { basePath: '', allowedDsns: [BROWSER_DSN] }; + + const response = await sentryTunnelRouteHandler(tunnelRequest(BROWSER_DSN)); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://o1.ingest.sentry.io/api/1/envelope/'), + expect.anything(), + ); + }); + + it('rejects envelopes for a DSN that is not allowed', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + globalWithInjectedValues._sentryManagedTunnel = { basePath: '', allowedDsns: [BROWSER_DSN] }; + + const response = await sentryTunnelRouteHandler(tunnelRequest('https://other@o1.ingest.sentry.io/2')); + + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + describe('setUpTunnelRewriteRules()', () => { it('rewrites envelope POSTs on a single segment to the tunnel route for a DSN-derived path', async () => { const config: NextConfigObject = {}; @@ -121,4 +174,10 @@ describe('ensureTunnelRouteFile()', () => { expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'src', 'app', 'sentry-tunnel', 'route.js')); }); + + it('uses the root `app` when the root has `pages`, as Next.js then ignores `src`', () => { + const projectDir = makeProjectDir('pages', 'src/utils'); + + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'app', 'sentry-tunnel', 'route.js')); + }); }); From 8cc50989dc691e42c89807210bae3154430eaf9e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 00:03:07 -0400 Subject: [PATCH 91/93] feat(nextjs): Export `isTunnelRequest` so middleware can let the tunnel through --- .../nextjs-16-tunnel/proxy.ts | 8 +++++++- .../tests/tunnel-dsn-path.test.ts | 8 ++++++++ .../nextjs-pages-dir/middleware.ts | 8 +++++++- .../tests/tunnel-dsn-path.test.ts | 8 ++++++++ packages/nextjs/src/common/index.ts | 2 +- packages/nextjs/src/common/isTunnelRequest.ts | 20 +++++++++++++++++++ packages/nextjs/src/config/types.ts | 5 ++--- .../nextjs/test/utils/tunnelRoute.test.ts | 15 ++++++++++++++ 8 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 packages/nextjs/src/common/isTunnelRequest.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts index 28639f60bbe4..0b8645eca9e2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts @@ -1,7 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -export async function proxy(_request: NextRequest) { +export async function proxy(request: NextRequest) { + // Stands in for auth middleware, without blocking the other tests. + if (request.headers.get('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) { + return new NextResponse(null, { status: 401 }); + } + return NextResponse.next(); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts index bf2488210666..2c110b6607ff 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts @@ -61,6 +61,14 @@ test('does not create server spans for tunnel requests', async ({ page }) => { expect(tunnelSpans).toEqual([]); }); +test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => { + const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' }; + const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`; + + expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200); + expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401); +}); + test('answers 404 to an envelope POST on any other path', async ({ request }) => { const response = await request.post('/notatunnel', { headers: { 'content-type': 'application/x-sentry-envelope' }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts index abc565f438b4..374f1e621c2e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts @@ -15,10 +15,16 @@ export async function middleware(request: NextRequest) { await fetch('http://localhost:3030/'); } + // Stands in for auth middleware, without blocking the other tests. + if (request.headers.has('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) { + return new NextResponse(null, { status: 401 }); + } + return NextResponse.next(); } // See "Matching Paths" below to learn more export const config = { - matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], + // The tunnel path of the test DSN, and a path next to it for the tunnel variant. + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware', '/qpo967', '/notatunnel'], }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts index d4b4aec4b227..cc6b54f6b15f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts @@ -63,6 +63,14 @@ test('does not create server spans for tunnel requests', async ({ page }) => { expect(tunnelSpans).toEqual([]); }); +test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => { + const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' }; + const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`; + + expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200); + expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401); +}); + test('answers 404 to an envelope POST on any other path', async ({ request }) => { const response = await request.post('/notatunnel', { headers: { 'content-type': 'application/x-sentry-envelope' }, diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index 3e655671c97f..bfe258d111af 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -12,4 +12,4 @@ export { wrapPageComponentWithSentry } from './pages-router-instrumentation/wrap export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; export { withServerActionInstrumentation } from './withServerActionInstrumentation'; export { captureRequestError } from './captureRequestError'; -export { getTunnelPath } from '@sentry/core'; +export { isTunnelRequest } from './isTunnelRequest'; diff --git a/packages/nextjs/src/common/isTunnelRequest.ts b/packages/nextjs/src/common/isTunnelRequest.ts new file mode 100644 index 000000000000..0ffdbce08b85 --- /dev/null +++ b/packages/nextjs/src/common/isTunnelRequest.ts @@ -0,0 +1,20 @@ +import type { TunnelRequestLike } from '@sentry/core'; +import { getClient, isTunnelRequest as isManagedTunnelRequest } from '@sentry/core'; +import { getInjectedManagedTunnelOptions } from './managedTunnelRoute'; + +/** + * Returns true if the request targets the tunnel `tunnelRoute` serves, so middleware that blocks unauthenticated + * requests can let it through: + * + * ```ts + * if (Sentry.isTunnelRequest(request)) { + * return NextResponse.next(); + * } + * ``` + */ +export function isTunnelRequest(request: TunnelRequestLike): boolean { + // On OpenNext the client is set up before the injected options are, so they are read here as well. + const tunnelOptions = getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); + + return !!tunnelOptions && isManagedTunnelRequest(request, tunnelOptions); +} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f064ffdd0395..8bea6ca30fc0 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -282,9 +282,8 @@ export type SentryBuildOptions = Omit< * ``` * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your - * middleware redirects or blocks unauthenticated requests, let the tunnel path through: the string you set, or - * `getTunnelPath(dsn)` from `@sentry/nextjs` for `true`. Don't exempt requests by their `content-type` header, - * since any client can set it. + * middleware redirects or blocks unauthenticated requests, let them through with `isTunnelRequest(request)` from + * `@sentry/nextjs`. Don't exempt requests by their `content-type` header, since any client can set it. * * NOTE: This feature only works with Next.js 11+ */ diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 2e978c8bb9c4..73353c9d0da3 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -5,6 +5,7 @@ import type { BrowserOptions } from '@sentry/react'; import { getTunnelPath } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; +import { isTunnelRequest } from '../../src/common/isTunnelRequest'; import { sentryTunnelRouteHandler } from '../../src/common/sentryTunnelRouteHandler'; import { ensureTunnelRouteFile, @@ -113,6 +114,20 @@ describe('sentryTunnelRouteHandler()', () => { }); }); +describe('isTunnelRequest()', () => { + it('matches POSTs to the tunnel of the injected options', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '/base', path: '/monitoring' }; + + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/base/monitoring' })).toBe(true); + expect(isTunnelRequest({ method: 'GET', url: 'http://localhost/base/monitoring' })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/base/admin' })).toBe(false); + }); + + it('matches nothing without `tunnelRoute`', () => { + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/monitoring' })).toBe(false); + }); +}); + describe('setUpTunnelRewriteRules()', () => { it('rewrites envelope POSTs on a single segment to the tunnel route for a DSN-derived path', async () => { const config: NextConfigObject = {}; From b3177a22c4d763d88243c1d048c06d6f169c0e30 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 10:34:57 -0400 Subject: [PATCH 92/93] fix(nextjs): Serve the server's own DSN path alongside `allowedDsns` --- packages/nextjs/src/common/managedTunnelRoute.ts | 6 +++++- packages/nextjs/src/config/types.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/common/managedTunnelRoute.ts b/packages/nextjs/src/common/managedTunnelRoute.ts index a67fe14dbdf9..c9ca011548c8 100644 --- a/packages/nextjs/src/common/managedTunnelRoute.ts +++ b/packages/nextjs/src/common/managedTunnelRoute.ts @@ -47,7 +47,11 @@ export function getManagedTunnelSpanFilter( tunnelOptions: TunnelOptions, dsn: string | undefined, ): NonNullable[number] | undefined { - const paths = getTunnelPaths({ ...tunnelOptions, allowedDsns: tunnelOptions.allowedDsns ?? (dsn ? [dsn] : []) }); + // `init` runs this before the client exists, so the own DSN is added here. + const paths = getTunnelPaths({ + ...tunnelOptions, + allowedDsns: [...(dsn ? [dsn] : []), ...(tunnelOptions.allowedDsns ?? [])], + }); if (!paths.length) { return undefined; } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 8bea6ca30fc0..a9149cad1621 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -273,7 +273,7 @@ export type SentryBuildOptions = Omit< * - Pass `true` to derive an ad-blocker-resistant path from the DSN, the same on the server and in the browser * - Pass a string path (e.g., '/monitoring') to use a fixed route * - Pass `{ path, allowedDsns }` to also forward envelopes for other DSNs, e.g. when the browser uses a different - * DSN than the server. Without `allowedDsns`, only the server's own DSN is forwarded. + * DSN than the server. The server's own DSN is always forwarded. * * The tunnel is answered by `app/sentry-tunnel/route.ts`, which the build creates if it is missing: * From 5bc562ccfa29a6a47da5b0e22a9e0c9a033859aa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:38 -0400 Subject: [PATCH 93/93] ref(nextjs): Import the tunnel helpers from @sentry/core/server --- packages/nextjs/src/common/isTunnelRequest.ts | 5 +++-- packages/nextjs/src/common/managedTunnelRoute.ts | 3 ++- packages/nextjs/src/common/sentryTunnelRouteHandler.ts | 3 ++- .../nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/common/isTunnelRequest.ts b/packages/nextjs/src/common/isTunnelRequest.ts index 0ffdbce08b85..f5c1ced22ba7 100644 --- a/packages/nextjs/src/common/isTunnelRequest.ts +++ b/packages/nextjs/src/common/isTunnelRequest.ts @@ -1,5 +1,6 @@ -import type { TunnelRequestLike } from '@sentry/core'; -import { getClient, isTunnelRequest as isManagedTunnelRequest } from '@sentry/core'; +import type { TunnelRequestLike } from '@sentry/core/server'; +import { getClient } from '@sentry/core'; +import { isTunnelRequest as isManagedTunnelRequest } from '@sentry/core/server'; import { getInjectedManagedTunnelOptions } from './managedTunnelRoute'; /** diff --git a/packages/nextjs/src/common/managedTunnelRoute.ts b/packages/nextjs/src/common/managedTunnelRoute.ts index c9ca011548c8..f64ede2ee616 100644 --- a/packages/nextjs/src/common/managedTunnelRoute.ts +++ b/packages/nextjs/src/common/managedTunnelRoute.ts @@ -1,6 +1,7 @@ import { HTTP_TARGET } from '@sentry/conventions/attributes'; import type { Options, TunnelOptions } from '@sentry/core'; -import { escapeStringForRegex, getTunnelPaths, GLOBAL_OBJ } from '@sentry/core'; +import { escapeStringForRegex, GLOBAL_OBJ } from '@sentry/core'; +import { getTunnelPaths } from '@sentry/core/server'; /** * The route the managed tunnel rewrite points at. The user's `app/sentry-tunnel/route.ts` answers it. diff --git a/packages/nextjs/src/common/sentryTunnelRouteHandler.ts b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts index b541eed68452..5ff2d153ec37 100644 --- a/packages/nextjs/src/common/sentryTunnelRouteHandler.ts +++ b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts @@ -1,5 +1,6 @@ import type { TunnelOptions } from '@sentry/core'; -import { getClient, handleTunnelRequest, isTunnelRequest } from '@sentry/core'; +import { getClient, handleTunnelRequest } from '@sentry/core'; +import { isTunnelRequest } from '@sentry/core/server'; import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from './managedTunnelRoute'; /** diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 3d92df95f9e7..f6a004bd7794 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,6 +1,7 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import type { RawAttributes } from '@sentry/core'; -import { getClient, isSentryRequestUrl, isTunnelRequest, type Span } from '@sentry/core'; +import { getClient, isSentryRequestUrl, type Span } from '@sentry/core'; +import { isTunnelRequest } from '@sentry/core/server'; import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_ROUTE } from '../managedTunnelRoute'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached';