Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1af8f48
feat(tanstackstart-react): Serve the managed tunnel from the server e…
logaretm Oct 15, 2026
d042461
feat(tanstackstart-react): Serve the managed tunnel under the base pa…
logaretm Oct 16, 2026
07ee6b2
fix(tanstackstart-react): Drop the tunnel's spans from the Vite dev s…
logaretm Oct 16, 2026
28acf34
fix(tanstackstart-react): Normalize the tunnel base path with the cor…
logaretm Oct 16, 2026
998046c
fix(tanstackstart-react): Pass a Worker's env and ctx through the tun…
logaretm Oct 18, 2026
1a03d84
fix(tanstackstart-react): Fail the build when a fixed tunnelRoute col…
logaretm Oct 19, 2026
92f4f82
docs(tanstackstart-react): Describe how tunnelRoute is served now
logaretm Oct 19, 2026
7351b68
ref(tanstackstart-react): Enable tunnelRoute on the client like init …
logaretm Oct 19, 2026
2a72e60
ref(tanstackstart-react): Report tunnelRoute and the base path once w…
logaretm Oct 19, 2026
668962b
test(tanstackstart-react): Send Content-Length with the tunnel test r…
logaretm Oct 19, 2026
9f15930
test(tanstackstart-react): Drop the tunnel-dev and tunnel-object vari…
logaretm Oct 19, 2026
32ff829
ref(tanstackstart-react): Let core skip an empty base path, and drop …
logaretm Oct 19, 2026
8c3eeaa
ref: Import the internal tunnel helpers under their `_INTERNAL_` names
logaretm Oct 22, 2026
1cde77e
ref(tanstackstart-react): Type the tunnel route options as server tun…
logaretm Oct 22, 2026
45af129
test: Stop checking the removed `X-Sentry-Tunnel` header
logaretm Oct 27, 2026
183bdc0
ref(tanstackstart-react): Pass the tunnel forwarder along with `tunne…
logaretm Oct 27, 2026
3c1bb08
docs(tanstackstart-react): Point `tunnelRoute` at `serveTunnelIntegra…
logaretm Oct 27, 2026
649ead6
fix(tanstackstart-react): Resolve the SDK from the app when wrapping …
logaretm Oct 27, 2026
98fa30b
fix(tanstackstart-react): Keep the server entry's prototype when wrap…
logaretm Oct 27, 2026
3826f0b
ref(tanstackstart-react): Export the server entry wrapper as `_INTERN…
logaretm Oct 27, 2026
9ad8395
ref(tanstackstart-react): Require the base path getter of the server …
logaretm Oct 27, 2026
79f6c8a
fix(tanstackstart-react): Keep rejecting non-POST requests to a stati…
logaretm Oct 27, 2026
fc3d34e
fix(tanstackstart-react): Reject non-POST requests to every managed t…
logaretm Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ 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,
tunnel: 'http://localhost:3031/',
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<Env>)(request, env, ctx);
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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.status()).toBe(403);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }),
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@
"test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
"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:hoisted": "pnpm install --config.node-linker=hoisted && 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-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-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": {
Expand Down Expand Up @@ -72,9 +73,14 @@
"assert-command": "pnpm test:assert:tunnel-custom"
},
{
"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"
},
{
"label": "tanstackstart-react (tunnel-basepath-dev)",
"build-command": "pnpm install",
"assert-command": "pnpm test:assert:tunnel-basepath-dev"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ 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'
: tunnelRouteMode === 'custom'
? '/custom-monitor'
: tunnelRouteMode === 'object'
? '/object-monitor'
: /^\/[a-z0-9]{8}$/;
: // The path `tunnelRoute: true` derives from the DSN.
new RegExp(`^${basePath}/[a-z0-9]{1,7}$`);

test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants');

Expand All @@ -19,7 +19,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
Expand Down Expand Up @@ -54,11 +54,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('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => {
Expand Down Expand Up @@ -101,7 +101,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();
Expand All @@ -127,7 +127,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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ function resolveTunnelRouteOption() {
return true;
case 'static':
return '/monitor';
case 'object':
return { path: '/object-monitor', allowedDsns: [appDsn] };
default:
return undefined;
}
Expand All @@ -45,7 +43,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(),
Expand Down
9 changes: 9 additions & 0 deletions packages/tanstackstart-react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,12 @@ 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.
*
* @internal
*/
export function _INTERNAL_wrapServerEntryWithSentryTunnel<T>(entry: T, _options?: unknown): T {
return entry;
}
18 changes: 14 additions & 4 deletions packages/tanstackstart-react/src/client/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { consoleSandbox } from '@sentry/core';
import { _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, 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;
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 });
}
Comment thread
cursor[bot] marked this conversation as resolved.

function applyManagedTunnelRoute(options: ReactBrowserOptions): void {
const managedTunnelRoute =
typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined;

Expand All @@ -18,7 +28,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
Expand Down
1 change: 1 addition & 0 deletions packages/tanstackstart-react/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 _INTERNAL_wrapServerEntryWithSentryTunnel: typeof serverSdk._INTERNAL_wrapServerEntryWithSentryTunnel;
6 changes: 5 additions & 1 deletion packages/tanstackstart-react/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 as _INTERNAL_wrapServerEntryWithSentryTunnel,
} from './tunnelRoute';

/**
* A no-op stub of the replay integration for the server. Router setup code is shared between client and server,
Expand Down
53 changes: 53 additions & 0 deletions packages/tanstackstart-react/src/server/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { URL_PATH } from '@sentry/conventions/attributes';
import type { ServerTunnelOptions } from '@sentry/core';
import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core';
import {
_INTERNAL_forwardTunnelRequest as forwardTunnelRequest,
handleTunnelRequestIfMatched,
isTunnelRequest,
_INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults,
} from '@sentry/core/server';
import type { ServerEntry } from './wrapFetchWithSentry';

const registeredTunnelRoutePaths = new Set<string>();

Expand Down Expand Up @@ -92,3 +100,48 @@ 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 every server client the same way `serveTunnelIntegration` does, unless
* that integration is set up too, and the tunnel is then served from the client's config like in every other SDK.
*
* @internal Only the code the Vite plugin generates calls this, so its parameters can change at any time.
*/
export function wrapServerEntryWithSentryTunnel<T extends ServerEntry>(
entry: T,
tunnelRoute?: ServerTunnelOptions,
basePath?: string,
): T {
// 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, forward: forwardTunnelRequest, 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.
if (typeof entry === 'function') {
return entry;
}

// Keeps the prototype, so a class instance entry keeps its other handlers, like `scheduled` or `queue`. `fetch` is
// defined rather than assigned, which a read-only `fetch` on a frozen prototype would reject.
return Object.create(Object.getPrototypeOf(entry), {
...Object.getOwnPropertyDescriptors(entry),
fetch: {
// Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush.
value: (request: Request, ...rest: unknown[]) => {
// Start would render the app for any other method on a tunnel path, running its root loaders.
if (request.method !== 'POST' && isTunnelRequest({ method: 'POST', url: request.url })) {
return new Response(null, { status: 405, headers: { Allow: 'POST' } });
}
Comment thread
cursor[bot] marked this conversation as resolved.

return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest);
},
writable: true,
configurable: true,
enumerable: true,
},
}) as T;
}
18 changes: 11 additions & 7 deletions packages/tanstackstart-react/src/vite/sentryTanstackStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugi
import { makeRoutePatternPlugin } from './routePatterns';
import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps';
import type { TunnelRouteOptions } from './tunnelRoute';
import { makeTunnelRoutePlugin } from './tunnelRoute';
import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute';

/**
* Build-time options for the Sentry TanStack Start SDK.
Expand Down Expand Up @@ -37,14 +37,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 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.
* - `{ 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.
Expand Down Expand Up @@ -77,7 +76,12 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase {
* @returns An array of Vite plugins
*/
export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] {
const plugins: Plugin[] = [makeRoutePatternPlugin()];
const tunnelBasePath = makeTunnelBasePathPlugin();
const plugins: Plugin[] = [
makeRoutePatternPlugin(),
tunnelBasePath.plugin,
makeServerEntryTunnelPlugin(options.tunnelRoute, tunnelBasePath.getBasePath),
Comment thread
logaretm marked this conversation as resolved.
];

if (options.tunnelRoute) {
plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug));
Expand Down
Loading
Loading