Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ef7b5e9
Add a TanStack Start tunnel route helper
nikolovlazar Apr 13, 2026
1691509
Merge branch 'develop' into lazarnikolov/js-2140-tanstack-start-tunne…
nikolovlazar Apr 13, 2026
316be89
Add TanStack Start tunnel route e2e coverage
nikolovlazar Apr 13, 2026
e6b6290
Merge branch 'lazarnikolov/js-2140-tanstack-start-tunnel-adapter' of …
nikolovlazar Apr 13, 2026
08b4154
Refactor TanStack Start e2e app to cover managed tunnel route variants
nikolovlazar Apr 15, 2026
3991017
Merge branch 'develop' into lazarnikolov/js-2140-tanstack-start-tunne…
nikolovlazar Apr 15, 2026
98202a9
Generate stable 8-character tunnel route paths
nikolovlazar Apr 15, 2026
320a55f
refactor(tanstackstart-react): mark tunnel route cache key internal
nikolovlazar Apr 16, 2026
73fbbeb
test(tanstackstart-react): narrow tunnel response matcher
nikolovlazar Apr 16, 2026
5a2c6e9
Merge branch 'develop' into lazarnikolov/js-2140-tanstack-start-tunne…
nikolovlazar Apr 16, 2026
ee3da8d
fix(tanstackstart-react): stub createSentryTunnelRoute on client
nikolovlazar Apr 16, 2026
1d827f5
Refactor TanStack Start tunnel route handling
nikolovlazar Apr 20, 2026
7c9124c
Merge branch 'develop' into lazarnikolov/js-2140-tanstack-start-tunne…
nikolovlazar Apr 20, 2026
4390705
Fix tunnel route lint issues
nikolovlazar Apr 20, 2026
e68a210
Refactor TanStack tunnel route config
nikolovlazar Apr 20, 2026
c993616
fix(tanstackstart-react): treat empty tunnel path as omitted
nikolovlazar Apr 21, 2026
52c33cb
fix(tanstackstart-react): use path in tunnelRoute tests and type options
nikolovlazar Apr 21, 2026
d77dec4
Merge branch 'develop' into lazarnikolov/js-2140-tanstack-start-tunne…
nikolovlazar Apr 21, 2026
29a5d89
style(tanstackstart-react): format sentryTanstackStart test imports
nikolovlazar Apr 21, 2026
c356b37
fix(tanstackstart-react): detect tunnel route conflicts under both ts…
nikolovlazar Apr 21, 2026
9e04b81
refactor(tanstackstart-react): simplify allowedDsns options check
nikolovlazar Apr 23, 2026
da03651
test(tanstackstart-react): add e2e variant for object-form tunnelRoute
nikolovlazar Apr 23, 2026
00c9a99
Merge branch 'develop' into lazarnikolov/js-2140-tanstack-start-tunne…
nikolovlazar Apr 23, 2026
af60d11
test(tanstackstart-react): drop tunnel chain from default test:assert
nikolovlazar Apr 23, 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 @@ -9,8 +9,15 @@
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"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-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build",
"test:assert": "pnpm test"
"test:assert:proxy": "pnpm test",
"test:assert": "pnpm test:assert:proxy && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build && pnpm test:assert:tunnel-generated && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build && pnpm test:assert:tunnel-static && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel-custom",
"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"
},
"dependencies": {
"@sentry/tanstackstart-react": "latest || *",
Expand All @@ -35,5 +42,24 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"label": "tunnel-generated",
"build-command": "pnpm test:build:tunnel-generated",
"assert-command": "pnpm test:assert:tunnel-generated"
},
{
"label": "tunnel-static",
"build-command": "pnpm test:build:tunnel-static",
"assert-command": "pnpm test:assert:tunnel-static"
},
{
"label": "tunnel-custom",
"build-command": "pnpm test:build:tunnel-custom",
"assert-command": "pnpm test:assert:tunnel-custom"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare const __APP_DSN__: string;
declare const __APP_TUNNEL__: string | undefined;
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ export const getRouter = () => {
if (!router.isServer) {
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: 'https://public@dsn.ingest.sentry.io/1337',
dsn: __APP_DSN__,
integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)],
// We recommend adjusting this value in production, or using tracesSampler
// for finer control
tracesSampleRate: 1.0,
release: 'e2e-test',
tunnel: 'http://localhost:3031/', // proxy server
tunnel: __APP_TUNNEL__,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Sentry from '@sentry/tanstackstart-react';
import { createFileRoute } from '@tanstack/react-router';

const USE_CUSTOM_TUNNEL_ROUTE = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';

const DEFAULT_DSN = 'https://public@dsn.ingest.sentry.io/1337';
const TUNNEL_DSN = 'http://public@localhost:3031/1337';

// Example of a manually defined tunnel endpoint without relying on the
// managed route injected by `sentryTanstackStart({ tunnelRoute: ... })`.
// If you use a custom route like this one, set `tunnel: '/custom-monitor'` in the client SDK's
// `Sentry.init()` call so browser events are sent to the same endpoint.
export const Route = createFileRoute('/custom-monitor')({
server: Sentry.createSentryTunnelRoute({
allowedDsns: [USE_CUSTOM_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN],
}),
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

const usesManagedTunnelRoute =
(process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';

test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant');

test('Sends client-side error to Sentry with auto-instrumentation', async ({ page }) => {
const errorEventPromise = waitForError('tanstackstart-react', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

const usesManagedTunnelRoute =
(process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';

test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant');

test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({
page,
}) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

const usesManagedTunnelRoute =
(process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';

test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant');

test('Sends a server function transaction with auto-instrumentation', async ({ page }) => {
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

const tunnelRouteMode =
process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off');
const expectedTunnelPathMatcher =
tunnelRouteMode === 'static' ? '/monitor' : tunnelRouteMode === 'custom' ? '/custom-monitor' : /^\/[a-z0-9]{8}$/;

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

test('Sends client-side errors through the configured tunnel route', async ({ page }) => {
const errorEventPromise = waitForError('tanstackstart-react', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error';
});

await page.goto('/');
const pageOrigin = new URL(page.url()).origin;

await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible();

const managedTunnelResponsePromise = page.waitForResponse(response => {
const responseUrl = new URL(response.url());

return (
responseUrl.origin === pageOrigin &&
response.request().method() === 'POST' &&
(typeof expectedTunnelPathMatcher === 'string'
? responseUrl.pathname === expectedTunnelPathMatcher
: expectedTunnelPathMatcher.test(responseUrl.pathname))
);
});
Comment thread
cursor[bot] marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tunnel test may capture unrelated Sentry request

Low Severity

The page.waitForResponse matcher accepts any POST to the tunnel path, but the Sentry SDK may send other events through the tunnel (e.g., pageload transactions, session data) before the click triggers the error event. The first matching response could be for a different event type, meaning the tunnel-response assertions validate a different request than the error. While the error event is independently awaited via waitForError, this means the test doesn't truly verify that the error event specifically transited the tunnel — it only proves some event did and the error arrived separately. This is a race condition between the pageload transaction POST and the error POST on the same tunnel endpoint.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 9e04b81. Configure here.


await page.locator('button').filter({ hasText: 'Break the client' }).click();

const managedTunnelResponse = await managedTunnelResponsePromise;
const managedTunnelUrl = new URL(managedTunnelResponse.url());
const errorEvent = await errorEventPromise;

expect(managedTunnelResponse.status()).toBe(200);
expect(managedTunnelUrl.origin).toBe(pageOrigin);

if (typeof expectedTunnelPathMatcher === 'string') {
expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher);
} else {
expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher);
expect(managedTunnelUrl.pathname).not.toBe('/monitor');
}
Comment thread
cursor[bot] marked this conversation as resolved.

expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error');
expect(errorEvent.transaction).toBe('/');
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,31 @@ import viteReact from '@vitejs/plugin-react-swc';
import { nitro } from 'nitro/vite';
import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite';

const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off';
const useManagedTunnelRoute = tunnelRouteMode !== 'off';
const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';

const appDsn =
useManagedTunnelRoute || useCustomTunnelRoute
? 'http://public@localhost:3031/1337'
: 'https://public@dsn.ingest.sentry.io/1337';

const appTunnel = useManagedTunnelRoute
? undefined
: useCustomTunnelRoute
? '/custom-monitor'
: 'http://localhost:3031/';

const tunnelRoute = tunnelRouteMode === 'dynamic' ? true : tunnelRouteMode === 'static' ? '/monitor' : undefined;

export default defineConfig({
server: {
port: 3000,
},
define: {
__APP_DSN__: JSON.stringify(appDsn),
__APP_TUNNEL__: appTunnel === undefined ? 'undefined' : JSON.stringify(appTunnel),
},
plugins: [
tsConfigPaths(),
tanstackStart(),
Expand All @@ -20,6 +41,7 @@ export default defineConfig({
project: process.env.E2E_TEST_SENTRY_PROJECT,
authToken: process.env.E2E_TEST_AUTH_TOKEN,
debug: true,
tunnelRoute,
}),
],
});
17 changes: 17 additions & 0 deletions packages/tanstackstart-react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703
/* eslint-disable import/export */
import type { TanStackMiddlewareBase } from '../common/types';
import type { CreateSentryTunnelRouteOptions } from '../server/tunnelRoute';

export * from '@sentry/react';

Expand All @@ -26,3 +27,19 @@ export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = { '~types':
* The actual implementation is server-only, but this stub is needed to prevent rendering errors.
*/
export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = { '~types': undefined, options: {} };

/**
* No-op stub for client-side builds.
* The actual implementation is server-only, but this stub is needed to prevent rendering errors.
*/
export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions): {
handlers: {
POST: () => Promise<Response>;
};
} {
return {
handlers: {
POST: async () => new Response(null, { status: 500 }),
},
};
}
2 changes: 2 additions & 0 deletions packages/tanstackstart-react/src/client/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Client } from '@sentry/core';
import { applySdkMetadata } from '@sentry/core';
import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react';
import { getDefaultIntegrations as getReactDefaultIntegrations, init as initReactSDK } from '@sentry/react';
import { applyTunnelRouteOption } from './tunnelRoute';

/**
* Initializes the TanStack Start React SDK
Expand All @@ -14,6 +15,7 @@ export function init(options: ReactBrowserOptions): Client | undefined {
...options,
};

applyTunnelRouteOption(sentryOptions);
applySdkMetadata(sentryOptions, 'tanstackstart-react', ['tanstackstart-react', 'react']);

return initReactSDK(sentryOptions);
Expand Down
35 changes: 35 additions & 0 deletions packages/tanstackstart-react/src/client/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { consoleSandbox } from '@sentry/core';
import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react';

declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 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()`.
*/
export function applyTunnelRouteOption(options: ReactBrowserOptions): void {
const managedTunnelRoute =
typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined;

if (!managedTunnelRoute) {
return;
}

if (options.tunnel) {
if (!hasWarnedAboutManagedTunnelRouteOverride) {
hasWarnedAboutManagedTunnelRouteOverride = true;
consoleSandbox(() => {
// eslint-disable-next-line no-console
console.warn(
'[@sentry/tanstackstart-react] `Sentry.init({ tunnel: ... })` overrides the managed `sentryTanstackStart({ tunnelRoute: ... })` route. Remove the runtime `tunnel` option if you want the managed tunnel route to be used.',
);
});
}

return;
}

options.tunnel = managedTunnelRoute;
}
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 @@ -42,3 +42,4 @@ export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewares
export declare const tanstackRouterBrowserTracingIntegration: typeof clientSdk.tanstackRouterBrowserTracingIntegration;
export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGlobalRequestMiddleware;
export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware;
export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute;
1 change: 1 addition & 0 deletions packages/tanstackstart-react/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { init } from './sdk';
export { wrapFetchWithSentry } from './wrapFetchWithSentry';
export { wrapMiddlewaresWithSentry } from './middleware';
export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware';
export { createSentryTunnelRoute } from './tunnelRoute';
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* A no-op stub of the browser tracing integration for the server. Router setup code is shared between client and server,
Expand Down
61 changes: 61 additions & 0 deletions packages/tanstackstart-react/src/server/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { dsnToString, getClient, handleTunnelRequest } from '@sentry/core';

export interface CreateSentryTunnelRouteOptions {
allowedDsns?: string[];
}

type SentryTunnelRouteHandlerContext = {
request: Request;
};

type SentryTunnelRoute = {
handlers: {
POST: (context: SentryTunnelRouteHandlerContext) => Promise<Response>;
};
};

/**
* Creates a TanStack Start server route configuration for tunneling Sentry envelopes.
*
* @example
* ```ts
* import { createFileRoute } from '@tanstack/react-router';
* import * as Sentry from '@sentry/tanstackstart-react';
*
* export const Route = createFileRoute('/monitoring')({
* server: Sentry.createSentryTunnelRoute({
* allowedDsns: ['https://public@o0.ingest.sentry.io/0'],
* }),
* });
* ```
*/
export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions): SentryTunnelRoute {
return {
handlers: {
POST: async ({ request }) => {
const allowedDsnsFromOptions =
options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined;
Comment thread
nikolovlazar marked this conversation as resolved.
Outdated

const allowedDsns =
allowedDsnsFromOptions ??
(() => {
const client = getClient();
const dsn = client?.getDsn();
return dsn ? [dsnToString(dsn)] : undefined;
})();

if (!allowedDsns) {
return new Response(
'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.',
{ status: 500 },
);
}

return handleTunnelRequest({
request,
allowedDsns,
});
},
},
};
}
1 change: 1 addition & 0 deletions packages/tanstackstart-react/src/vite/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { sentryTanstackStart } from './sentryTanstackStart';
export type { SentryTanstackStartOptions } from './sentryTanstackStart';
export type { TunnelRouteOptions } from './tunnelRoute';
Loading
Loading