Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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,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);
});
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,20 +54,19 @@ 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 }) => {
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hm, #24615 added the ANY handler that returned 405 Allow: POST, because without it "a non-POST (or rather GET) request to the route would cause TSS to render a page" (running root loaders). With the wrapper, only POSTs match, so a GET to /monitor falls through to Start, which SSR-renders the not-found page. The e2e test was changed from expecting 405 to expecting 404, and the test name now describes the new behavior as intended. The PR body does not mention this reversal.

Suggestion: either keep the 405 for non-POST requests on a fixed tunnelRoute path in wrapServerEntryWithSentryTunnel (the path is known there), or state in the PR body that this reverts #24615 on purpose and why. Note that for the DSN-derived path the old behavior can't hold anyway, since the path is no longer a route, so reverting may well be the right call.

});

function pathnameMatchesTunnelRoute(pathname: string): boolean {
Expand Down Expand Up @@ -101,7 +100,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 +126,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
7 changes: 7 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,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<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 });
}

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 wrapServerEntryWithSentryTunnel: typeof serverSdk.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,
} from './tunnelRoute';

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

const registeredTunnelRoutePaths = new Set<string>();

Expand Down Expand Up @@ -92,3 +98,34 @@ 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 `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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Recommend marking this @internal, and possibly exporting with a _INTERNAL_ prefix, since we're exposing some internal positional params. Or, if users should call this themselves, we could consider an options object like wrapServerEntryWithSentryTunnel(entry, { tunnelRoute, basePath })

*/
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, 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;
}

return {
...entry,
// Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush.
fetch(request: Request, ...rest: unknown[]) {
return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest);
},
};
Comment on lines +116 to +130

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

{ ...entry, fetch } copies only own enumerable properties. A default export that is a class instance (methods on the prototype, such as scheduled or queue) loses those methods. That is unusual for Start, but a Worker that does export default new MyHandler() would break at runtime only for the non-fetch events.

Suggestion: add a short comment that on Cloudflare the inner wrap serves the tunnel because the client is per-request. For the spread, use Object.create(entry) with a fetch override, or Object.assign(Object.create(Object.getPrototypeOf(entry)), entry, { fetch }), so prototype methods survive.

}
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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is always added, so every TanStack Start app that uses sentryTanstackStart() gets its server entry replaced by a generated module, with the original loaded through a ?sentry-tunnel-original query id. Before this PR, users without tunnelRoute got no change to their entry at all. The reason is valid, to support Sentry.init({ tunnel: true }) at runtime with no build option, but the cost falls on everyone:

  • Any other Vite plugin that matches the server entry by exact id (eg with transform(code, id) filters) now sees /src/server.ts?sentry-tunnel-original instead of /src/server.ts. That includes TanStack's own compiler plugins and Cloudflare's plugin. The e2e apps pass, but they cover a narrow set of setups.
  • The generated module adds a build-time dependency on TanStack internals for users who never asked for a tunnel. If TanStack changes the alias or default entry path, the wrapper silently stops applying, which is harmless. But if a future Start version wraps or transforms the entry differently, the query-id indirection is a new failure point.
  • Per request, it adds a getServedTunnel() lookup. That cost is small.

Suggestion: state the tradeoff in the PR body, because the description reads as "only routes matching tunnel requests".

We could also consider an opt-out (eg tunnelRoute: false to disable wrapping). At minimum, add an e2e or unit check that an app with no tunnel and a user src/server.ts with named exports still builds and serves the same as before, so that if TanStack changes something in a way that breaks us, we'll catch it early.

];

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