Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 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 || *",
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,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],
}),
});
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,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))
);
});
Comment thread
cursor[bot] marked this conversation as resolved.

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
@@ -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(),
Expand All @@ -20,6 +46,7 @@ export default defineConfig({
project: process.env.E2E_TEST_SENTRY_PROJECT,
authToken: process.env.E2E_TEST_AUTH_TOKEN,
debug: true,
tunnelRoute,
}),
],
});
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
37 changes: 37 additions & 0 deletions packages/tanstackstart-react/src/client/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -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;
}
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';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing client-side stub for createSentryTunnelRoute

High Severity

createSentryTunnelRoute is exported only from the server entry but has no client-side no-op stub. The documented manual usage pattern imports * as Sentry in a route file and calls Sentry.createSentryTunnelRoute(...) at the module top level. TanStack Start does not automatically strip server-only code from client bundles, so this module evaluation on the client will fail with a TypeError because the function is undefined. Other server-only exports like wrapMiddlewaresWithSentry, sentryGlobalRequestMiddleware, and sentryGlobalFunctionMiddleware all have client-side stubs in src/client/index.ts for exactly this reason.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a2c6e9. Configure here.


/**
* A no-op stub of the browser tracing integration for the server. Router setup code is shared between client and server,
Expand Down
43 changes: 43 additions & 0 deletions packages/tanstackstart-react/src/server/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -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<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 }) => {
return handleTunnelRequest({
request,
allowedDsns: options.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';
26 changes: 24 additions & 2 deletions packages/tanstackstart-react/src/vite/sentryTanstackStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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 }));
Expand Down
Loading
Loading