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 bcfb3279f684..1b6d9761fc94 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -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 pnpm build", + "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static 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 pnpm test", + "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm test", + "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test" }, "dependencies": { "@sentry/tanstackstart-react": "latest || *", @@ -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" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts new file mode 100644 index 000000000000..6e7d31c7a4e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts @@ -0,0 +1,2 @@ +declare const __APP_DSN__: string; +declare const __APP_TUNNEL__: string | undefined; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx index b1c6f7727a26..9a39b6f35c42 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx @@ -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__, }); } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts new file mode 100644 index 000000000000..dd069a821059 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts @@ -0,0 +1,18 @@ +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], + }), +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts index 04d93e550824..a49b77a293b1 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts @@ -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'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts index dffab8ea2aa3..ab31ce5e022a 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts @@ -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, }) => { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts index 5186514d277a..f5e70a676432 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -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 ( 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 new file mode 100644 index 000000000000..d5bf24e72c97 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -0,0 +1,56 @@ +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)) + ); + }); + + 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'); + } + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); + expect(errorEvent.transaction).toBe('/'); +}); 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 a2b39609717d..8ee4c5ce28a2 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 @@ -1,14 +1,40 @@ -import { defineConfig } from 'vite'; -import tsConfigPaths from 'vite-tsconfig-paths'; -import { tanstackStart } from '@tanstack/react-start/plugin/vite'; -import viteReact from '@vitejs/plugin-react-swc'; -import { nitro } from 'nitro/vite'; -import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +import { defineConfig } from "vite"; +import tsConfigPaths from "vite-tsconfig-paths"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +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" + ? { allowedDsns: [appDsn], tunnel: true as const } + : tunnelRouteMode === "static" + ? { allowedDsns: [appDsn], tunnel: "/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(), @@ -20,6 +46,7 @@ export default defineConfig({ project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, debug: true, + tunnelRoute, }), ], }); diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 3e762580830c..7607c32faaa3 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -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'; @@ -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; + }; +} { + return { + handlers: { + POST: async () => new Response(null, { status: 500 }), + }, + }; +} diff --git a/packages/tanstackstart-react/src/client/sdk.ts b/packages/tanstackstart-react/src/client/sdk.ts index b0ee3b53053f..0998c027f112 100644 --- a/packages/tanstackstart-react/src/client/sdk.ts +++ b/packages/tanstackstart-react/src/client/sdk.ts @@ -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 @@ -14,6 +15,7 @@ export function init(options: ReactBrowserOptions): Client | undefined { ...options, }; + applyTunnelRouteOption(sentryOptions); applySdkMetadata(sentryOptions, 'tanstackstart-react', ['tanstackstart-react', 'react']); return initReactSDK(sentryOptions); diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts new file mode 100644 index 000000000000..8817a6637f55 --- /dev/null +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -0,0 +1,37 @@ +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; +} diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 76e4ced73c92..79edcef0bbfa 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -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; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 4fe781b6d778..0ae0968e574b 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -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'; /** * A no-op stub of the browser tracing 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 new file mode 100644 index 000000000000..44ff32934a56 --- /dev/null +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -0,0 +1,43 @@ +import { handleTunnelRequest } from '@sentry/core'; + +export interface CreateSentryTunnelRouteOptions { + allowedDsns: string[]; +} + +type SentryTunnelRouteHandlerContext = { + request: Request; +}; + +type SentryTunnelRoute = { + handlers: { + POST: (context: SentryTunnelRouteHandlerContext) => Promise; + }; +}; + +/** + * 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 }) => { + return handleTunnelRequest({ + request, + allowedDsns: options.allowedDsns, + }); + }, + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/index.ts b/packages/tanstackstart-react/src/vite/index.ts index 85143344028d..b7f65e26f5d2 100644 --- a/packages/tanstackstart-react/src/vite/index.ts +++ b/packages/tanstackstart-react/src/vite/index.ts @@ -1,2 +1,3 @@ export { sentryTanstackStart } from './sentryTanstackStart'; export type { SentryTanstackStartOptions } from './sentryTanstackStart'; +export type { TunnelRouteOptions } from './tunnelRoute'; diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index fd5d5b2f0d05..17cbc1b465a1 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,6 +2,8 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; +import type { TunnelRouteOptions } from './tunnelRoute'; +import { makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -19,6 +21,18 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @default true */ autoInstrumentMiddleware?: boolean; + + /** + * 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. Use `tunnel: true` to generate an opaque route path per dev session + * or production build, or provide a static absolute path string to control the route name yourself. + * + * 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. + */ + tunnelRoute?: TunnelRouteOptions; } /** @@ -46,13 +60,21 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - // only add plugins in production builds + const tunnelRoutePlugin = options.tunnelRoute + ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) + : undefined; + + // only add build-time plugins in production builds if (process.env.NODE_ENV === 'development') { - return []; + return tunnelRoutePlugin ? [tunnelRoutePlugin] : []; } const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + if (tunnelRoutePlugin) { + plugins.push(tunnelRoutePlugin); + } + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts new file mode 100644 index 000000000000..a64c98edbf6d --- /dev/null +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -0,0 +1,207 @@ +import type { Plugin } from "vite"; + +export interface TunnelRouteOptions { + /** + * A list of DSNs that are allowed to use the managed tunnel route. + */ + allowedDsns: string[]; + + /** + * Controls the public route path used by the managed tunnel route. + * + * - `true` generates an opaque path once per dev session or production build. + * - `'/custom-path'` uses a fixed absolute route path. + * + * @default true + */ + tunnel?: true | 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; +} + +function validateTunnelRouteOptions(options: TunnelRouteOptions): string { + if (options.allowedDsns.length === 0) { + throw new Error( + "[@sentry/tanstackstart-react] `sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN.", + ); + } + + const tunnelRoute = options.tunnel ?? true; + + if ( + typeof tunnelRoute === "string" && + (!tunnelRoute.startsWith("/") || + tunnelRoute.includes("?") || + tunnelRoute.includes("#")) + ) { + throw new Error( + "[@sentry/tanstackstart-react] `tunnelRoute.tunnel` must be `true` or an absolute route path starting with `/` and without query or hash segments.", + ); + } + + return resolveTunnelRoute(tunnelRoute); +} + +function hasRouteConflict( + source: string, + resolvedTunnelRoute: string, +): boolean { + return ( + source.includes(`fullPath: '${resolvedTunnelRoute}'`) || + source.includes(`path: '${resolvedTunnelRoute}'`) || + source.includes(`id: '${resolvedTunnelRoute}'`) + ); +} + +function injectAfterLastImport(source: string, statement: string): string { + const importMatches = [...source.matchAll(/^import .+$/gm)]; + const lastImport = importMatches.at(-1); + + if (!lastImport || 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.`, + ); + } + + 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 || 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`.", + ); + } + + 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; +} + +export function makeTunnelRoutePlugin( + options: TunnelRouteOptions, + debug?: boolean, +): Plugin { + const resolvedTunnelRoute = validateTunnelRouteOptions(options); + const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); + const serializedAllowedDsns = JSON.stringify(options.allowedDsns); + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`, + ); + } + + return { + name: "sentry-tanstackstart-tunnel-route", + enforce: "pre", + config() { + return { + define: { + __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, + }, + }; + }, + 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'; + +export const Route = createFileRoute(${serializedTunnelRoute})({ + server: { + handlers: { + async POST({ request }) { + const Sentry = await import('@sentry/tanstackstart-react'); + return Sentry.createSentryTunnelRoute({ + allowedDsns: ${serializedAllowedDsns}, + }).handlers.POST({ request }); + }, + }, + }, +}); +`; + }, + transform(source, id) { + if ( + !id.endsWith("/routeTree.gen.ts") && + !id.endsWith("\\routeTree.gen.ts") + ) { + return null; + } + + return injectManagedTunnelRoute(source, resolvedTunnelRoute); + }, + }; +} diff --git a/packages/tanstackstart-react/test/client/sdk.test.ts b/packages/tanstackstart-react/test/client/sdk.test.ts index 4cba4a199ef5..400bbe877dc1 100644 --- a/packages/tanstackstart-react/test/client/sdk.test.ts +++ b/packages/tanstackstart-react/test/client/sdk.test.ts @@ -9,6 +9,7 @@ describe('TanStack Start React Client SDK', () => { describe('init', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); it('Adds TanStack Start React client metadata to the SDK options', () => { @@ -41,5 +42,15 @@ describe('TanStack Start React Client SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('applies the managed tunnel route when no runtime tunnel is provided', () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: '/managed-tunnel' })); + }); }); }); diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts new file mode 100644 index 000000000000..90b91481305b --- /dev/null +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -0,0 +1,59 @@ +import type { BrowserOptions } from '@sentry/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('applyTunnelRouteOption()', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('applies the managed tunnel route when no runtime tunnel is set', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/managed-tunnel'); + }); + + it('does not override an explicit runtime tunnel and warns instead', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + 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: '/runtime-tunnel', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/runtime-tunnel'); + expect(warnSpy).toHaveBeenCalledWith( + '[@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.', + ); + }); + + it('does nothing when no managed tunnel route was injected', async () => { + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBeUndefined(); + }); +}); diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..b638b779e39e --- /dev/null +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const handleTunnelRequestSpy = vi.fn(); + +vi.mock('@sentry/core', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + handleTunnelRequest: (...args: unknown[]) => handleTunnelRequestSpy(...args), + }; +}); + +const { createSentryTunnelRoute } = await import('../../src/server/tunnelRoute'); + +describe('createSentryTunnelRoute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns a server route config with only a POST handler', () => { + const route = createSentryTunnelRoute({ + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + }); + + expect(Object.keys(route.handlers)).toEqual(['POST']); + expect(route.handlers.POST).toBeTypeOf('function'); + }); + + it('forwards the request and allowed DSNs to handleTunnelRequest', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + const allowedDsns = ['https://public@o0.ingest.sentry.io/0']; + const response = new Response('ok', { status: 200 }); + + handleTunnelRequestSpy.mockResolvedValueOnce(response); + + const route = createSentryTunnelRoute({ allowedDsns }); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); + const [options] = handleTunnelRequestSpy.mock.calls[0]!; + expect(options).toEqual({ + request, + allowedDsns, + }); + expect(options.allowedDsns).toBe(allowedDsns); + expect(result).toBe(response); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index ef18da74d03a..004251c63b66 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -2,6 +2,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; +import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -28,6 +29,12 @@ const mockMiddlewarePlugin: Plugin = { transform: vi.fn(), }; +const mockTunnelRoutePlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-route', + enforce: 'pre', + transform: vi.fn(), +}; + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -37,6 +44,10 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); +vi.mock('../../src/vite/tunnelRoute', () => ({ + makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), +})); + describe('sentryTanstackStart()', () => { beforeEach(() => { vi.clearAllMocks(); @@ -54,7 +65,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); }); - it('returns no plugins in development mode', () => { + it('returns no plugins in development mode when tunnelRoute is not configured', () => { process.env.NODE_ENV = 'development'; const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); @@ -62,6 +73,17 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([]); }); + it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { + process.env.NODE_ENV = 'development'; + + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, + }); + + expect(plugins).toEqual([mockTunnelRoutePlugin]); + }); + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false, @@ -127,4 +149,29 @@ describe('sentryTanstackStart()', () => { expect(makeAutoInstrumentMiddlewarePlugin).toHaveBeenCalledWith({ enabled: true, debug: undefined }); }); }); + + describe('managed tunnel route', () => { + it('includes the managed tunnel route plugin in production when configured', () => { + const plugins = sentryTanstackStart({ + tunnelRoute: { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + tunnel: '/monitor', + }, + sourcemaps: { disable: true }, + }); + + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockTunnelRoutePlugin, mockMiddlewarePlugin]); + }); + + it('passes tunnelRoute options through to the tunnel route plugin', () => { + const tunnelRoute = { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + tunnel: '/monitor' as const, + }; + + sentryTanstackStart({ tunnelRoute, sourcemaps: { disable: true } }); + + expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(tunnelRoute, undefined); + }); + }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts new file mode 100644 index 000000000000..c9411dc1745a --- /dev/null +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; +import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; + +const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; + +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 = {', +); + +describe('tunnelRoute vite plugin', () => { + beforeEach(() => { + delete process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + }); + + afterEach(() => { + delete process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + }); + + 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('rejects empty allowedDsns', () => { + expect(() => makeTunnelRoutePlugin({ allowedDsns: [] })).toThrow( + '`sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN', + ); + }); + + it('rejects invalid static tunnel routes', () => { + expect(() => makeTunnelRoutePlugin({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'], tunnel: 'monitor' })).toThrow( + '`tunnelRoute.tunnel` must be `true` or an absolute route path', + ); + }); + + it('injects the managed tunnel route into the generated TanStack route tree', () => { + const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + + 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,'); + }); + + it('injects the managed tunnel route when rootRouteChildren is untyped', () => { + const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); + + expect(transformedRouteTree).toContain('const rootRouteChildren = {'); + expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + }); + + 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.', + ); + }); + + it('loads a virtual managed tunnel route module for a static tunnel path', async () => { + const plugin = makeTunnelRoutePlugin({ + allowedDsns: ['http://public@localhost:3031/1337'], + tunnel: '/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', + ); + + 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"]'); + }); +});