From da52a2e28bfb89b52304f7056db0d76a31e5a207 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 14:43:47 -0500 Subject: [PATCH 1/3] ref: Remove the framework tunnel hooks The runtimes answer managed tunnel requests before any framework sees them, so the per-framework hooks in Astro, Nitro, Nuxt, React Router, Remix, SolidStart, SvelteKit and TanStack Start go. The frameworks only set the tunnel's base path. Astro now initializes the server SDK from the server entry in builds, which the late init in its middleware used to cover. --- .../tanstackstart-react/tests/tunnel.test.ts | 9 -- docs/repeated-init.md | 2 +- packages/astro/src/integration/index.ts | 4 +- packages/astro/src/integration/serverInit.ts | 20 +++- packages/astro/src/server/middleware.ts | 10 +- packages/astro/src/server/sdk.ts | 12 +- packages/astro/test/server/middleware.test.ts | 22 ---- packages/nitro/src/runtime/plugins/server.ts | 14 +-- .../runtime/plugins/tunnel-legacy.server.ts | 29 +---- .../nuxt/src/runtime/plugins/tunnel.server.ts | 19 +--- packages/nuxt/src/vite/nitroServer.ts | 2 +- .../react-router/src/server/tunnelRoute.ts | 67 +---------- .../src/vite/makeTunnelRoutePlugin.ts | 4 +- .../test/server/tunnelRoute.test.ts | 90 --------------- packages/remix/src/v3/server/instrument.ts | 19 +--- packages/remix/src/v3/server/middleware.ts | 6 - packages/remix/test/v3/instrument.test.ts | 30 ----- packages/remix/test/v3/middleware.test.ts | 13 --- packages/solidstart/src/nitro/tunnelPlugin.ts | 32 +----- .../test/nitro/tunnelPlugin.test.ts | 90 --------------- .../sveltekit/src/server-common/handle.ts | 11 +- .../test/server-common/handle.test.ts | 41 ------- .../src/server/tunnelRoute.ts | 37 +----- .../wrapServerEntryWithSentryTunnel.test.ts | 107 ------------------ 24 files changed, 67 insertions(+), 623 deletions(-) delete mode 100644 packages/react-router/test/server/tunnelRoute.test.ts delete mode 100644 packages/solidstart/test/nitro/tunnelPlugin.test.ts 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 index d42510f36173..b845965f5caf 100644 --- 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 @@ -61,15 +61,6 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(errorEvent.transaction).toBe(`${basePath}/`); }); -test('Rejects non-POST requests to the managed tunnel route instead of rendering 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'); -}); - function pathnameMatchesTunnelRoute(pathname: string): boolean { return typeof expectedTunnelPathMatcher === 'string' ? pathname === expectedTunnelPathMatcher diff --git a/docs/repeated-init.md b/docs/repeated-init.md index a8f6445f0ffc..f584225a858b 100644 --- a/docs/repeated-init.md +++ b/docs/repeated-init.md @@ -37,7 +37,7 @@ When `init()` replaces a client, nothing closes the old one: | Entry point | Repeated call | | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `initAndBind` (browser and its wrappers, Deno), Node `_init`, Vercel Edge | Warns, then replaces the client. Returns the new client. | -| Next.js server, Remix server, Hono Node | Keeps the first client and returns it. Logs in debug mode only. | +| Next.js server, Remix server, Astro server, Hono Node | Keeps the first client and returns it. Logs in debug mode only. | | Hono Bun and Deno | Keeps the first client and returns it. Warns with its own text. | | Nuxt server | Keeps the first client and returns it. Logs that a `--import` preload is no longer needed. | | Cloudflare (default) | Keeps the first client of the isolate and returns it, unless that client is closed or closing. `cacheClient: false` makes a new client on each call, with no warning. | diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 0b39040d198b..36c6421b72d7 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -198,7 +198,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) // and also serves as the fallback for non-Cloudflare adapters in production. if (serverInitSnippet) { - updateConfig({ vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet)] } }); + updateConfig({ + vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet, { injectIntoEntry: !isCloudflare })] }, + }); injectScript('page-ssr', SERVER_INIT_IMPORT); } diff --git a/packages/astro/src/integration/serverInit.ts b/packages/astro/src/integration/serverInit.ts index 111ad3c06b42..830ba6c20f46 100644 --- a/packages/astro/src/integration/serverInit.ts +++ b/packages/astro/src/integration/serverInit.ts @@ -14,12 +14,18 @@ export const SERVER_INIT_IMPORT = `import "${SERVER_INIT_MODULE}";\n`; /** * Serves the server init as one module that SSR pages and the Sentry middleware both import, so it runs once, on - * whichever request comes first. With only the `page-ssr` script, a request that renders no SSR page, like a tunnel - * request from a prerendered page on a cold server, would find no client. + * whichever request comes first. With `injectIntoEntry`, a build also imports it from the server entry, so the SDK is + * set up before the server takes its first request: a tunnel request from a prerendered page on a cold server renders + * no SSR page and would otherwise reach the app before the runtime answers tunnel requests. */ -export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin { +export function makeServerInitVitePlugin(serverInitSnippet: string, { injectIntoEntry = false } = {}): VitePlugin { + let isBuild = false; + return { name: 'sentry-astro-server-init', + configResolved: config => { + isBuild = config.command === 'build'; + }, resolveId: id => (id === SERVER_INIT_MODULE || id === SENTRY_MIDDLEWARE_MODULE ? `\0${id}` : null), load: id => { if (id === `\0${SERVER_INIT_MODULE}`) { @@ -30,5 +36,13 @@ export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin ? `${SERVER_INIT_IMPORT}export { onRequest } from "@sentry/astro/middleware";\n` : null; }, + transform(code, id, options) { + // The dev server's module graph has no server entry, and an import added to what it marks as one hangs it. + if (!injectIntoEntry || !isBuild || !options?.ssr || !this.getModuleInfo(id)?.isEntry) { + return null; + } + + return { code: `${SERVER_INIT_IMPORT}${code}`, map: null }; + }, }; } diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 410fe0c9a8be..e6a72f4b9dbe 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -26,10 +26,7 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { - flushIfServerless, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, -} from '@sentry/core/server'; +import { flushIfServerless } from '@sentry/core/server'; import { captureException, continueTrace, @@ -90,11 +87,6 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } - const tunnelResponse = handleServedTunnelRequest(ctx.request); - if (tunnelResponse) { - return tunnelResponse; - } - const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); // For static (prerendered) routes, we only want to inject the parametrized route meta tags diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 98910a1db2fd..98fc2d44e198 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -1,8 +1,9 @@ import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import { applySdkMetadata } from '@sentry/core'; +import { _INTERNAL_getActiveClient, applySdkMetadata, debug } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; +import { DEBUG_BUILD } from '../debug-build'; declare const EdgeRuntime: string | undefined; declare const Deno: unknown; @@ -19,6 +20,15 @@ export function init(options: NodeOptions): NodeClient | undefined { return undefined; } + // The integration imports the server config from the server entry and from SSR pages, and a `node --import` + // preload of it is supported too. The first init wins. + const existingClient = _INTERNAL_getActiveClient(); + if (existingClient) { + DEBUG_BUILD && debug.log('SDK already initialized'); + + return existingClient; + } + const opts = { ...options, }; diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index fd9f0dc2a139..b205af292372 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -2,7 +2,6 @@ import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } f import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import * as SentryCoreServer from '@sentry/core/server'; import * as SentryNode from '@sentry/node'; import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -34,27 +33,6 @@ const STATIC_REQUEST_CONTEXT = { url: new URL('https://myDomain.io/users/'), }; -describe('sentryMiddleware with the managed tunnel', () => { - it('answers tunnel requests without calling the rest of the middleware chain', async () => { - const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); - const tunnelResponse = new Response('forwarded'); - const matcherSpy = vi - .spyOn(SentryCoreServer, '_INTERNAL_handleServedTunnelRequest') - .mockReturnValue(Promise.resolve(tunnelResponse)); - const next = vi.fn(); - const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); - - const response = await handleRequest()({ ...DYNAMIC_REQUEST_CONTEXT, request } as unknown as APIContext, next); - - expect(response).toBe(tunnelResponse); - expect(matcherSpy).toHaveBeenCalledWith(request); - expect(next).not.toHaveBeenCalled(); - - getClientSpy.mockRestore(); - matcherSpy.mockRestore(); - }); -}); - describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index d6a4f13898b1..5f5827812e5e 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,7 +1,4 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { definePlugin } from 'nitro'; import { useRuntimeConfig } from 'nitro/runtime-config'; // @ts-expect-error - This is a virtual module @@ -13,13 +10,8 @@ export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); instrumentTasks(tasks); - // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block - // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, - // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it, - // unless a framework serves the app under another prefix: `@sentry/solidstart` sets `sentryTunnelBasePath` to Vite's - // `base`. + // The runtime answers managed tunnel requests, under Nitro's `baseURL` unless a framework serves the app under + // another prefix: `@sentry/solidstart` sets `sentryTunnelBasePath` to Vite's `base`. const runtimeConfig = useRuntimeConfig() as { app?: { baseURL?: string }; sentryTunnelBasePath?: string }; setTunnelDefaults({ basePath: runtimeConfig.sentryTunnelBasePath || runtimeConfig.app?.baseURL }); - const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index da950ea3ea4f..51aed67bffed 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,33 +1,12 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; -import { eventHandler, toWebRequest } from 'h3'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import type { NitroAppPlugin } from 'nitropack'; import { useRuntimeConfig } from 'nitropack/runtime'; /** - * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2), from the first layer of the h3 app. That runs - * before route rules and the app's `server/middleware`. + * Serves the managed tunnel under Nuxt's `app.baseURL` on Nuxt v3 and v4 (Nitro v2). The runtime answers the + * requests. */ -export default (nitroApp => { - // The first layer sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. +export default (() => { // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. setTunnelDefaults({ basePath: useRuntimeConfig().app?.baseURL }); - - // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule - // that layer throws for writing headers on the sent response. - nitroApp.h3App.stack.unshift({ - route: '', - handler: eventHandler(event => { - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isServedTunnelRequest({ method: event.method, url: event.path })) { - return undefined; - } - - // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. - return handleServedTunnelRequest(toWebRequest(event)); - }), - }); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 9a63b6fc7d68..611466e662df 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,20 +1,11 @@ -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { useRuntimeConfig } from 'nitro/runtime-config'; -import type { NitroAppPlugin, NitroApp } from 'nitro/types'; +import type { NitroAppPlugin } from 'nitro/types'; /** - * Answers managed tunnel requests on Nuxt v5+ (Nitro v3+). + * Serves the managed tunnel under Nuxt's `app.baseURL` on Nuxt v5+ (Nitro v3+). The runtime answers the requests. */ -export default ((nitroApp: NitroApp) => { - // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would - // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes - // first, before route rules and middleware. - // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. `useRuntimeConfig` also picks - // up a `NUXT_APP_BASE_URL` set at runtime. +export default (() => { + // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. setTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); - const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 0a7f358f1182..7401a6c1596d 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -120,7 +120,7 @@ function setupDeprecatedInjectMode( } /** - * Adds the plugin that answers the managed tunnel. The Cloudflare setup has no server config file, so it skips + * Adds the plugin that sets the managed tunnel's base path. The Cloudflare setup has no server config file, so it skips * `setupNitroServer` and adds this on its own. */ export function addTunnelPlugin(serverApi: ServerApi): void { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 7b8541f40d2a..7b6d257f5c31 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,70 +1,15 @@ -import { debug, getClient } from '@sentry/core'; -import { - _INTERNAL_getTunnelPaths as getTunnelPaths, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; -import { DEBUG_BUILD } from '../common/debug-build'; - -// Never matches a request, for when the managed tunnel is off. -const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; /** - * The resource routes that serve the managed tunnel, one per tunnel path. `sentryReactRouter()` adds them to the - * server build, since React Router has no request hook to answer from and a route matches only one path. Not meant - * to be used directly. + * Serves the managed tunnel under the app's `basename`, which React Router leaves in the request URL. The runtime + * answers the requests, so it adds no routes. `sentryReactRouter()` calls it from the server build. Not meant to be + * used directly. * - * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. + * @param basename The app's `basename`. */ export function createSentryTunnelRoutes(basename?: string): Record> { // Runs when the server build loads. Clients created later, e.g. per request on Cloudflare, start with the basename. setTunnelDefaults({ basePath: basename }); - // `init` runs from `--import` before the server build loads, so the client and its tunnel paths are usually known. - if (!getClient()) { - return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', getLazyTunnelPath) }; - } - - return Object.fromEntries( - getTunnelPaths().map((path, index) => { - const id = `sentry-tunnel-${index}`; - - return [id, makeTunnelRoute(id, () => path)]; - }), - ); -} - -/** - * The path of the single route added when `init` runs after the server build loads, e.g. per request on Cloudflare. - * React Router reads it on its first request and keeps it until the server restarts. - */ -function getLazyTunnelPath(): string { - if (!getClient()) { - DEBUG_BUILD && debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); - return DISABLED_TUNNEL_PATH; - } - - const [path, ...otherPaths] = getTunnelPaths(); - if (otherPaths.length) { - DEBUG_BUILD && debug.warn(`Sentry.init ran after the server build loaded, so only ${path} is served.`); - } - - return path ?? DISABLED_TUNNEL_PATH; -} - -function makeTunnelRoute(id: string, getPath: () => string): Record { - return { - id, - parentId: undefined, - index: undefined, - caseSensitive: undefined, - get path(): string { - return getPath(); - }, - module: { - action: ({ request }: { request: Request }) => - handleServedTunnelRequest(request) ?? new Response(null, { status: 404 }), - loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), - }, - }; + return {}; } diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts index 5d2c12cb244c..b3cda4d94507 100644 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -3,8 +3,8 @@ import type { Plugin } from 'vite'; const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; /** - * Adds the managed tunnel routes to the server build. They only serve requests when `serveTunnelIntegration` is set - * up. The server build exports the app's `basename`, which the route needs to match the request URL. + * Passes the app's `basename` from the server build to the managed tunnel, which serves under it. Only applies when + * `serveTunnelIntegration` is set up. */ export function makeTunnelRoutePlugin(): Plugin { return { diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts deleted file mode 100644 index 23c5739458a5..000000000000 --- a/packages/react-router/test/server/tunnelRoute.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSentryTunnelRoutes } from '../../src/server/tunnelRoute'; - -const DSN = 'https://public@o1.ingest.sentry.io/1337'; - -type TunnelRoute = { - path: string; - parentId: undefined; - module: { action: (args: { request: Request }) => Promise | Response; loader: () => Response }; -}; - -function firstRoute(basename?: string): TunnelRoute { - return Object.values(createSentryTunnelRoutes(basename))[0] as TunnelRoute; -} - -/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ -function setClient(managedTunnel: object | undefined): void { - const options = { dsn: DSN }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - if (managedTunnel) { - serveTunnelIntegration(managedTunnel).setup!(client); - } -} - -const ENVELOPE = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - -describe('createSentryTunnelRoutes', () => { - afterEach(() => { - getCurrentScope().setClient(undefined); - _INTERNAL_resetTunnelDefaults(); - vi.unstubAllGlobals(); - }); - - it('is a top-level route at the tunnel path when `serveTunnelIntegration` is set up', () => { - setClient({}); - const route = firstRoute(); - - expect(route.parentId).toBeUndefined(); - expect(route.path).toBe(getTunnelPath(DSN)); - }); - - it('adds no route when the tunnel is off, and a path that never matches when Sentry is not initialized yet', () => { - const route = firstRoute(); - const pathWithoutClient = route.path; - setClient(undefined); - - expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); - expect(route.path).toBe('/__sentry-tunnel-disabled__'); - expect(createSentryTunnelRoutes()).toEqual({}); - }); - - it('forwards tunnel POSTs and rejects other methods', async () => { - setClient({}); - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve(new Response('forwarded'))), - ); - const route = firstRoute(); - const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { - method: 'POST', - body: ENVELOPE, - headers: { 'content-length': String(ENVELOPE.length) }, - }); - - expect(await (await route.module.action({ request })).text()).toBe('forwarded'); - expect(route.module.loader().status).toBe(405); - }); - - it('serves the tunnel under `basename`, which React Router leaves in the request URL', async () => { - setClient({}); - vi.stubGlobal( - 'fetch', - vi.fn(() => Promise.resolve(new Response('forwarded'))), - ); - const route = firstRoute('/app/'); - const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { - method: 'POST', - body: ENVELOPE, - headers: { 'content-length': String(ENVELOPE.length) }, - }); - - // React Router matches the route under `basename` itself, so the path stays relative to it. - expect(route.path).toBe(getTunnelPath(DSN)); - expect(await (await route.module.action({ request })).text()).toBe('forwarded'); - }); -}); diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index f63578e2f0f9..0094d56e7594 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,7 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; import { consoleSandbox } from '@sentry/core'; -import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import type { OrchestrionChannelContext } from '@sentry/server-utils'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; @@ -59,7 +58,7 @@ function subscribeToCreateRouter(): void { } /** - * Report errors from any fetch handler, router or not, and answer managed tunnel requests before it runs. + * Report errors from any fetch handler, router or not. * The middleware only sees failures inside a router; this is the only hook that covers a plain handler. * No abort guard is needed, because the listener drops aborted requests before calling `onError`. */ @@ -72,7 +71,6 @@ function subscribeToCreateRequestListener(): void { start(data) { try { injectOnError(ensureOptions(data.arguments, 1)); - wrapFetchHandler(data.arguments); } catch { // Ignored on purpose. } @@ -84,21 +82,6 @@ function subscribeToCreateRequestListener(): void { }); } -/** - * Answers managed tunnel requests before the app's fetch handler, so code the app runs in front of its router never - * sees them. The router middleware still answers them for apps served some other way. - */ -function wrapFetchHandler(args: unknown[]): void { - const handler = args[0]; - if (typeof handler !== 'function') { - return; - } - - args[0] = function sentryFetchHandler(this: unknown, request: Request, ...rest: unknown[]): unknown { - return handleServedTunnelRequest(request) ?? handler.call(this, request, ...rest); - }; -} - function injectOnError(raw: Record | undefined): void { if (!raw || markInjected(raw)) { return; diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 4ca54f648383..f2ce59c48f01 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -10,7 +10,6 @@ import { updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; -import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; @@ -26,11 +25,6 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { - const tunnelResponse = handleServedTunnelRequest(context.request); - if (tunnelResponse) { - return tunnelResponse; - } - const isolationScope = getIsolationScope(); isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) }); diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index 85133e8b3ecd..cf22efc91609 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -144,33 +144,3 @@ describe('the createRequestListener error hook', () => { expect(args[1]).toEqual(expect.objectContaining({ onError: expect.any(Function) })); }); }); - -describe('the createRequestListener tunnel', () => { - beforeAll(() => { - instrumentRemixV3(); - }); - - it("answers a managed tunnel request without calling the app's handler", async () => { - const appHandler = vi.fn(() => new Response('app')); - const args: unknown[] = [appHandler]; - - publishCreateRequestListener(args); - - expect(await (args[0] as (request: Request) => unknown)(new Request(TUNNEL_URL, { method: 'POST' }))).toBe( - tunnelResponse, - ); - expect(appHandler).not.toHaveBeenCalled(); - }); - - it("hands every other request to the app's handler", () => { - const appResponse = new Response('app'); - const appHandler = vi.fn(() => appResponse); - const args: unknown[] = [appHandler]; - const request = new Request('http://localhost/page'); - - publishCreateRequestListener(args); - - expect((args[0] as (request: Request, client: unknown) => unknown)(request, 'client')).toBe(appResponse); - expect(appHandler).toHaveBeenCalledWith(request, 'client'); - }); -}); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index e0f1866e78fd..8a8e1e5db21b 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -40,19 +40,6 @@ describe('sentryRemixMiddleware', () => { handleServedTunnelRequest.mockReturnValue(undefined); }); - it('answers tunnel requests before route matching and the rest of the chain', async () => { - const tunnelResponse = new Response('forwarded'); - handleServedTunnelRequest.mockReturnValue(Promise.resolve(tunnelResponse)); - const next = vi.fn(); - const context = contextFor('http://x/abc123'); - - const response = await sentryRemixMiddleware(matcherFor('/'))(context, next); - - expect(response).toBe(tunnelResponse); - expect(handleServedTunnelRequest).toHaveBeenCalledWith(context.request); - expect(next).not.toHaveBeenCalled(); - }); - it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }); const middleware = sentryRemixMiddleware(matcherFor('/users/:id')); diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index d423c8839fe0..857dfa8f66af 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,37 +1,15 @@ import { GLOBAL_OBJ } from '@sentry/core'; -import { - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; -import { eventHandler, toWebRequest } from 'h3'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import type { NitroAppPlugin } from 'nitropack'; import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; /** - * Answers managed tunnel requests on SolidStart v1 (Nitro v2), from the first layer of the h3 app. That runs before - * route rules, the app's middleware and routing. `withSentry` registers it. + * Serves the managed tunnel under the app's base path on SolidStart v1 (Nitro v2). The runtime answers the requests. + * `withSentry` registers it. */ -export default (nitroApp => { - // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. The server - // bundle's banner sets the global before this plugin runs. +export default (() => { + // The server bundle's banner sets the global before this plugin runs. setTunnelDefaults({ basePath: (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], }); - - // Not the `request` hook: h3 still runs the first layer after a hook answered, so route rules would act on the - // sent response. - nitroApp.h3App.stack.unshift({ - route: '', - handler: eventHandler(event => { - // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isServedTunnelRequest({ method: event.method, url: event.path })) { - return undefined; - } - - // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which - // aren't Node streams. A returned `Response` is sent and ends the request, `undefined` hands it on. - return handleServedTunnelRequest(toWebRequest(event)); - }), - }); }) satisfies NitroAppPlugin; diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts deleted file mode 100644 index 4baea8a5a12f..000000000000 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -import { createApp, eventHandler, setHeaders, toNodeListener } from 'h3'; -import { fetchNodeRequestHandler } from 'node-mock-http'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import tunnelPlugin from '../../src/nitro/tunnelPlugin'; - -const DSN = 'https://public@o1.ingest.sentry.io/1337'; - -type RequestHook = (event: unknown) => Promise; - -/** - * Calls an h3 app wired like Nitro v2's: the `request` hook runs in `onRequest`, and the route rules layer, here - * with a `headers` rule, comes before the app. Requests come in the way serverless presets pass them through - * `localFetch`, with node-mock-http's request, which isn't a Node stream. - */ -function fetchApp(url: string, init: RequestInit): Promise { - const requestHooks: RequestHook[] = []; - const app = createApp({ - onRequest: async event => { - for (const hook of requestHooks) { - await hook(event); - } - }, - onError: error => onError(error), - }); - app.use(eventHandler(event => setHeaders(event, { 'x-route-rule': '1' }))); - app.use(eventHandler(() => 'app')); - (tunnelPlugin as (nitroApp: unknown) => void)({ - h3App: app, - hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && requestHooks.push(fn) }, - }); - - return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); -} - -const onError = vi.fn(); - -describe('SolidStart v1 tunnel plugin', () => { - afterEach(() => { - getCurrentScope().setClient(undefined); - _INTERNAL_resetTunnelDefaults(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - it('answers tunnel requests before route rules and the app with `serveTunnelIntegration`', async () => { - const options = { dsn: DSN }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - serveTunnelIntegration().setup!(client); - const sentryFetch = vi.fn((_url: string, _init: RequestInit) => Promise.resolve(new Response('forwarded'))); - vi.stubGlobal('fetch', sentryFetch); - const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - const response = await fetchApp(getTunnelPath(DSN)!, { - method: 'POST', - body: envelope, - headers: { 'content-length': String(envelope.length) }, - }); - - expect(await response.text()).toBe('forwarded'); - expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); - // A route rule that ran after the answer would fail to set headers on the sent response. - expect(response.headers.get('x-route-rule')).toBeNull(); - expect(onError).not.toHaveBeenCalled(); - }); - - it('serves no tunnel without `serveTunnelIntegration`', async () => { - const options = { dsn: DSN }; - getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); - const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - const response = await fetchApp(getTunnelPath(DSN)!, { - method: 'POST', - body: envelope, - headers: { 'content-length': String(envelope.length) }, - }); - - expect(await response.text()).toBe('app'); - }); - - it('leaves every other request to the app', async () => { - const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); - - expect(await response.text()).toBe('app'); - expect(response.headers.get('x-route-rule')).toBe('1'); - }); -}); diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 38291b226679..15d2a88413f4 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -19,11 +19,7 @@ import { withIsolationScope, filterCollectedUrl, } from '@sentry/core'; -import { - flushIfServerless, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_setTunnelDefaults as setTunnelDefaults, -} from '@sentry/core/server'; +import { flushIfServerless, _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { getTunnelBasePath } from '../common/tunnel'; import type { Handle, ResolveOptions } from './handleTypes'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -301,11 +297,6 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { - const tunnelResponse = handleServedTunnelRequest(input.event.request); - if (tunnelResponse) { - return tunnelResponse; - } - const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent; // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 9e55877cfdad..6d3ddadc53ae 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -4,7 +4,6 @@ import { getCapturedScopesOnSpan, getRootSpan, getSpanDescendants, spanIsSampled import * as SentryCore from '@sentry/core'; import { NodeClient, serveTunnelIntegration, setCurrentClient } from '@sentry/node'; import { _INTERNAL_resetTunnelDefaults } from '@sentry/core/server'; -import { TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; import type { Handle } from '../../src/server-common/handleTypes'; import { redirect } from '@sveltejs/kit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -146,16 +145,6 @@ describe('sentryHandle with span streaming', () => { describe('sentryHandle with the managed tunnel', () => { const DSN = 'https://public@o1.ingest.sentry.io/1337'; - function makeTunnelRequest(path: string): Request { - const body = SentryCore.serializeEnvelope(SentryCore.createEnvelope({ dsn: DSN }, [])) as string; - - return new Request(`http://localhost:3000${path}`, { - method: 'POST', - body, - headers: { 'content-length': String(body.length) }, - }); - } - beforeEach(() => { vi.stubGlobal( 'fetch', @@ -171,36 +160,6 @@ describe('sentryHandle with the managed tunnel', () => { _INTERNAL_resetTunnelDefaults(); }); - it('answers tunnel requests without resolving the route', async () => { - const mockResolve = vi.fn(); - - const response = await sentryHandle()({ - event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), - resolve: mockResolve, - }); - - expect(await response.text()).toBe('forwarded'); - expect(mockResolve).not.toHaveBeenCalled(); - }); - - it('serves the tunnel under the base path `sentrySvelteKit()` set before the handle was created', async () => { - vi.stubGlobal(TUNNEL_BASE_PATH_GLOBAL, '/app'); - const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); - const handle = sentryHandle(); - - const underBasePath = await handle({ - event: mockEvent({ request: makeTunnelRequest(`/app${SentryCore.getTunnelPath(DSN)!}`), route: { id: null } }), - resolve: mockResolve, - }); - const atRoot = await handle({ - event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), - resolve: mockResolve, - }); - - expect(await underBasePath.text()).toBe('forwarded'); - expect(await atRoot.text()).toBe('app'); - }); - it('resolves every other request', async () => { const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index cd0c4a0e1389..efba3e1722ab 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -3,8 +3,6 @@ import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; import { _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, - _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, - _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setTunnelDefaults as setTunnelDefaults, } from '@sentry/core/server'; import type { ServerEntry } from './wrapFetchWithSentry'; @@ -99,8 +97,9 @@ 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. + * Enables the managed tunnel for the server entry, which the runtime then answers. The `sentryTanstackStart` Vite + * plugin applies this to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes + * on. * * `tunnelRoute` enables the managed tunnel on every server client the same way `serveTunnelIntegration` does, unless * that integration is set up too, and the tunnel is then served from the client's config like in every other SDK. @@ -112,33 +111,9 @@ export function wrapServerEntryWithSentryTunnel( 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. + // Runs when the server entry loads, before any request. Requests still carry Start's base path, so the tunnel is + // served under it. setTunnelDefaults({ tunnel: tunnelRoute, forward: forwardTunnelRequest, basePath }); - // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served - // by the wrapped default server entry it calls. - if (typeof entry === 'function') { - return entry; - } - - // Keeps the prototype, so a class instance entry keeps its other handlers, like `scheduled` or `queue`. `fetch` is - // defined rather than assigned, which a read-only `fetch` on a frozen prototype would reject. - return Object.create(Object.getPrototypeOf(entry), { - ...Object.getOwnPropertyDescriptors(entry), - fetch: { - // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. - value: (request: Request, ...rest: unknown[]) => { - // Start would render the app for any other method on a tunnel path, running its root loaders. - if (request.method !== 'POST' && isServedTunnelRequest({ method: 'POST', url: request.url })) { - return new Response(null, { status: 405, headers: { Allow: 'POST' } }); - } - - return handleServedTunnelRequest(request) ?? entry.fetch(request, ...rest); - }, - writable: true, - configurable: true, - enumerable: true, - }, - }) as T; + return entry; } diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts index 7d3fa6d030d3..d373d8fc84be 100644 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -38,15 +38,6 @@ describe('wrapServerEntryWithSentryTunnel', () => { vi.clearAllMocks(); }); - it('answers the tunnel path when `serveTunnelIntegration` is set up, without calling the app', async () => { - setClient({}); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); - - expect(await response.text()).toBe('forwarded'); - expect(appFetch).not.toHaveBeenCalled(); - }); - it('hands every request to the app when the managed tunnel is off', async () => { setClient(undefined); @@ -56,40 +47,6 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(sentryFetch).not.toHaveBeenCalled(); }); - it('serves the tunnel with build-time options even without `serveTunnelIntegration`', async () => { - setClient(undefined); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( - makeEnvelopeRequest(TUNNEL_URL), - ); - - expect(await response.text()).toBe('forwarded'); - }); - - it('rejects other methods on a fixed `tunnelRoute` path instead of rendering the app', async () => { - setClient(undefined); - const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; - const wrapped = wrapServerEntryWithSentryTunnel(entry, { path: '/monitor' }); - - const response = await wrapped.fetch(new Request('http://localhost:3000/monitor')); - - expect(response.status).toBe(405); - expect(response.headers.get('allow')).toBe('POST'); - expect(appFetch).not.toHaveBeenCalled(); - expect(await (await wrapped.fetch(new Request('http://localhost:3000/other'))).text()).toBe('app'); - }); - - it('rejects other methods on the DSN-derived tunnel path too', async () => { - setClient({}); - const entry: { fetch: (request: Request) => Response } = { fetch: appFetch }; - - const response = await wrapServerEntryWithSentryTunnel(entry).fetch(new Request(TUNNEL_URL)); - - expect(response.status).toBe(405); - expect(response.headers.get('allow')).toBe('POST'); - expect(appFetch).not.toHaveBeenCalled(); - }); - it('passes other requests and their options to the app', async () => { setClient({}); const request = makeEnvelopeRequest('http://localhost:3000/api/users'); @@ -110,39 +67,6 @@ describe('wrapServerEntryWithSentryTunnel', () => { expect(appFetch).toHaveBeenCalledWith(request, env, ctx); }); - it("keeps a class instance entry's prototype handlers, like a Worker's `scheduled`", async () => { - setClient({}); - class Handler { - public fetch(_request: Request): Response { - return new Response('app'); - } - - public scheduled(): string { - return 'scheduled'; - } - } - - const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); - - expect(wrapped.scheduled()).toBe('scheduled'); - expect(wrapped).toBeInstanceOf(Handler); - expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); - }); - - it('wraps an entry whose prototype is frozen', async () => { - setClient({}); - class Handler { - public fetch(_request: Request): Response { - return new Response('app'); - } - } - Object.freeze(Handler.prototype); - - const wrapped = wrapServerEntryWithSentryTunnel(new Handler()); - - expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('forwarded'); - }); - it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { class Worker { public fetch(): Response { @@ -166,35 +90,4 @@ describe('wrapServerEntryWithSentryTunnel', () => { ), ).toBe(true); }); - - it('keeps the options of `serveTunnelIntegration` over `tunnelRoute`', async () => { - setClient({ path: '/from-init' }); - const options = getCurrentScope().getClient()!.getOptions(); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, { path: '/from-build' }).fetch( - makeEnvelopeRequest('http://localhost:3000/from-init'), - ); - - expect(options._managedTunnel).toEqual({ path: '/from-init' }); - expect(await response.text()).toBe('forwarded'); - }); - - it('answers the tunnel under the base path Start routes on, and only there', async () => { - setClient({}); - const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); - - expect( - await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/app${getTunnelPath(DSN)}`))).text(), - ).toBe('forwarded'); - expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('app'); - }); - - it('keeps a base path `serveTunnelIntegration` set', async () => { - setClient({ basePath: '/custom' }); - const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); - - expect( - await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/custom${getTunnelPath(DSN)}`))).text(), - ).toBe('forwarded'); - }); }); From 031964514e3df610d37b34030addeff8339935ff Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 14:53:29 -0500 Subject: [PATCH 2/3] ref(react-router,tanstackstart-react): Name the tunnel helpers after what they do now `createSentryTunnelRoutes` created no routes and `wrapServerEntryWithSentryTunnel` wrapped nothing since the runtime answers tunnel requests. They're now `_INTERNAL_setTunnelBasePath` and `_INTERNAL_setTunnelRoute`, which the generated code calls for their side effect. --- packages/react-router/src/cloudflare/index.ts | 2 +- packages/react-router/src/server/index.ts | 2 +- .../react-router/src/server/tunnelRoute.ts | 9 +- ...ePlugin.ts => makeTunnelBasePathPlugin.ts} | 10 +- packages/react-router/src/vite/plugin.ts | 4 +- .../vite/makeTunnelBasePathPlugin.test.ts | 20 ++++ .../test/vite/makeTunnelRoutePlugin.test.ts | 28 ------ .../react-router/test/vite/plugin.test.ts | 16 ++-- .../tanstackstart-react/src/client/index.ts | 6 +- .../tanstackstart-react/src/index.types.ts | 2 +- .../tanstackstart-react/src/server/index.ts | 2 +- .../src/server/tunnelRoute.ts | 17 +--- .../src/vite/tunnelRoute.ts | 17 ++-- .../test/server/setTunnelRoute.test.ts | 38 ++++++++ .../wrapServerEntryWithSentryTunnel.test.ts | 93 ------------------- .../test/vite/tunnelRoute.test.ts | 27 +++--- 16 files changed, 105 insertions(+), 188 deletions(-) rename packages/react-router/src/vite/{makeTunnelRoutePlugin.ts => makeTunnelBasePathPlugin.ts} (60%) create mode 100644 packages/react-router/test/vite/makeTunnelBasePathPlugin.test.ts delete mode 100644 packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts create mode 100644 packages/tanstackstart-react/test/server/setTunnelRoute.test.ts delete mode 100644 packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index 481d2169cf07..f28bb1d4924e 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -1,7 +1,7 @@ export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; -export { createSentryTunnelRoutes } from '../server/tunnelRoute'; +export { setTunnelBasePath as _INTERNAL_setTunnelBasePath } from '../server/tunnelRoute'; export { createSentryServerInstrumentation, type CreateSentryServerInstrumentationOptions, diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 71d37c6ca114..06f1e335410e 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -9,7 +9,7 @@ export { createSentryHandleRequest, type SentryHandleRequestOptions } from './cr export type { SentryWebStreamHandleRequestOptions } from './handleRequestTypes'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; -export { createSentryTunnelRoutes } from './tunnelRoute'; +export { setTunnelBasePath as _INTERNAL_setTunnelBasePath } from './tunnelRoute'; // React Router instrumentation API support export { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 7b6d257f5c31..1d10aa356f19 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,15 +1,12 @@ import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; /** - * Serves the managed tunnel under the app's `basename`, which React Router leaves in the request URL. The runtime - * answers the requests, so it adds no routes. `sentryReactRouter()` calls it from the server build. Not meant to be - * used directly. + * Serves the managed tunnel under the app's `basename`, which React Router leaves in the request URL. + * `sentryReactRouter()` calls it from the server build. * * @param basename The app's `basename`. */ -export function createSentryTunnelRoutes(basename?: string): Record> { +export function setTunnelBasePath(basename?: string): void { // Runs when the server build loads. Clients created later, e.g. per request on Cloudflare, start with the basename. setTunnelDefaults({ basePath: basename }); - - return {}; } diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelBasePathPlugin.ts similarity index 60% rename from packages/react-router/src/vite/makeTunnelRoutePlugin.ts rename to packages/react-router/src/vite/makeTunnelBasePathPlugin.ts index b3cda4d94507..d74fcf4b7fe9 100644 --- a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts +++ b/packages/react-router/src/vite/makeTunnelBasePathPlugin.ts @@ -6,9 +6,9 @@ const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; * Passes the app's `basename` from the server build to the managed tunnel, which serves under it. Only applies when * `serveTunnelIntegration` is set up. */ -export function makeTunnelRoutePlugin(): Plugin { +export function makeTunnelBasePathPlugin(): Plugin { return { - name: 'sentry-react-router-tunnel-route', + name: 'sentry-react-router-tunnel-base-path', enforce: 'post', transform(code, id, options) { @@ -18,10 +18,8 @@ export function makeTunnelRoutePlugin(): Plugin { return { code: `${code} -import { createSentryTunnelRoutes as __sentryCreateTunnelRoutes } from '@sentry/react-router'; -if (typeof routes === 'object' && routes) { - Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined)); -} +import { _INTERNAL_setTunnelBasePath as __sentrySetTunnelBasePath } from '@sentry/react-router'; +__sentrySetTunnelBasePath(typeof basename === 'string' ? basename : undefined); `, map: null, }; diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 2992b9a5bbc0..ced7b61048ec 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -4,7 +4,7 @@ import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from './makeServerBuildCapturePlugin'; -import { makeTunnelRoutePlugin } from './makeTunnelRoutePlugin'; +import { makeTunnelBasePathPlugin } from './makeTunnelBasePathPlugin'; import type { SentryReactRouterBuildOptions } from './types'; /** @@ -22,7 +22,7 @@ export async function sentryReactRouter( plugins.push(makeConfigInjectorPlugin(options)); plugins.push(makeServerBuildCapturePlugin()); - plugins.push(makeTunnelRoutePlugin()); + plugins.push(makeTunnelBasePathPlugin()); if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); diff --git a/packages/react-router/test/vite/makeTunnelBasePathPlugin.test.ts b/packages/react-router/test/vite/makeTunnelBasePathPlugin.test.ts new file mode 100644 index 000000000000..9925ca578ccc --- /dev/null +++ b/packages/react-router/test/vite/makeTunnelBasePathPlugin.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { makeTunnelBasePathPlugin } from '../../src/vite/makeTunnelBasePathPlugin'; + +type Transform = (code: string, id: string, options?: { ssr?: boolean }) => { code: string } | null; + +describe('makeTunnelBasePathPlugin', () => { + const transform = makeTunnelBasePathPlugin().transform as unknown as Transform; + + it('passes the basename of the SSR server build to the tunnel', () => { + const result = transform('export const basename = "/app";', '\0virtual:react-router/server-build', { ssr: true }); + + expect(result?.code).toContain("__sentrySetTunnelBasePath(typeof basename === 'string' ? basename : undefined);"); + expect(result?.code).toContain("from '@sentry/react-router'"); + }); + + it('leaves other modules and the client build alone', () => { + expect(transform('', '\0virtual:react-router/server-build', { ssr: false })).toBeNull(); + expect(transform('', '/app/root.tsx', { ssr: true })).toBeNull(); + }); +}); diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts deleted file mode 100644 index d520315a07bc..000000000000 --- a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; - -type Transform = (code: string, id: string, options?: { ssr?: boolean }) => { code: string } | null; - -describe('makeTunnelRoutePlugin', () => { - const transform = makeTunnelRoutePlugin().transform as unknown as Transform; - - it('adds the tunnel route to the SSR server build', () => { - const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); - - expect(result?.code).toContain( - " Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined));", - ); - expect(result?.code).toContain("from '@sentry/react-router'"); - }); - - it('only adds the route when the server build declares `routes`, so a build without it still loads', () => { - const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); - - expect(result?.code).toContain("if (typeof routes === 'object' && routes) {"); - }); - - it('leaves other modules and the client build alone', () => { - expect(transform('', '\0virtual:react-router/server-build', { ssr: false })).toBeNull(); - expect(transform('', '/app/root.tsx', { ssr: true })).toBeNull(); - }); -}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 5c33619821df..b2c9774027d6 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -3,7 +3,7 @@ import { makeConfigInjectorPlugin } from '../../src/vite/makeConfigInjectorPlugi import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from '../../src/vite/makeServerBuildCapturePlugin'; -import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; +import { makeTunnelBasePathPlugin } from '../../src/vite/makeTunnelBasePathPlugin'; import { sentryReactRouter } from '../../src/vite/plugin'; vi.spyOn(console, 'log').mockImplementation(() => { @@ -17,7 +17,7 @@ vi.mock('../../src/vite/makeCustomSentryVitePlugins'); vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); vi.mock('../../src/vite/makeConfigInjectorPlugin'); vi.mock('../../src/vite/makeServerBuildCapturePlugin'); -vi.mock('../../src/vite/makeTunnelRoutePlugin'); +vi.mock('../../src/vite/makeTunnelBasePathPlugin'); // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. @@ -33,7 +33,7 @@ describe('sentryReactRouter', () => { const mockSourceMapsPlugin = { name: 'source-maps-plugin' }; const mockConfigInjectorPlugin = { name: 'sentry-config-injector' }; const mockServerBuildCapturePlugin = { name: 'sentry-react-router-server-build-capture' }; - const mockTunnelRoutePlugin = { name: 'sentry-react-router-tunnel-route' }; + const mockTunnelBasePathPlugin = { name: 'sentry-react-router-tunnel-base-path' }; const mockOrchestrionPlugin = { name: 'sentry-orchestrion-vite' }; beforeEach(() => { @@ -42,7 +42,7 @@ describe('sentryReactRouter', () => { vi.mocked(makeEnableSourceMapsPlugin).mockReturnValue(mockSourceMapsPlugin); vi.mocked(makeConfigInjectorPlugin).mockReturnValue(mockConfigInjectorPlugin); vi.mocked(makeServerBuildCapturePlugin).mockReturnValue(mockServerBuildCapturePlugin); - vi.mocked(makeTunnelRoutePlugin).mockReturnValue(mockTunnelRoutePlugin); + vi.mocked(makeTunnelBasePathPlugin).mockReturnValue(mockTunnelBasePathPlugin); }); afterEach(() => { @@ -55,7 +55,7 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelBasePathPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); @@ -65,7 +65,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelBasePathPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -73,7 +73,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelBasePathPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -87,7 +87,7 @@ describe('sentryReactRouter', () => { expect(result).toEqual([ mockConfigInjectorPlugin, mockServerBuildCapturePlugin, - mockTunnelRoutePlugin, + mockTunnelBasePathPlugin, mockOrchestrionPlugin, mockSourceMapsPlugin, ...mockPlugins, diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index b157b4f31173..ad7946b233b4 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -64,10 +64,8 @@ export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions 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. + * No-op stub for client-side builds. The server entry that calls it only runs on the server. * * @internal */ -export function _INTERNAL_wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { - return entry; -} +export function _INTERNAL_setTunnelRoute(_tunnelRoute?: unknown, _basePath?: string): void {} diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 9b81217a239a..b06807027a1c 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -49,4 +49,4 @@ export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGloba export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; export declare const registerSentryServerTunnelRoute: typeof serverSdk.registerSentryServerTunnelRoute; -export declare const _INTERNAL_wrapServerEntryWithSentryTunnel: typeof serverSdk._INTERNAL_wrapServerEntryWithSentryTunnel; +export declare const _INTERNAL_setTunnelRoute: typeof serverSdk._INTERNAL_setTunnelRoute; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index cef85cd391c4..b537fd9cfd01 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -12,7 +12,7 @@ export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from '. export { createSentryTunnelRoute, registerSentryServerTunnelRoute, - wrapServerEntryWithSentryTunnel as _INTERNAL_wrapServerEntryWithSentryTunnel, + setTunnelRoute as _INTERNAL_setTunnelRoute, } from './tunnelRoute'; /** diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index efba3e1722ab..b1f87d3751fd 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -5,7 +5,6 @@ import { _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, _INTERNAL_setTunnelDefaults as setTunnelDefaults, } from '@sentry/core/server'; -import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -97,23 +96,15 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) } /** - * Enables the managed tunnel for the server entry, which the runtime then answers. The `sentryTanstackStart` Vite - * plugin applies this to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes - * on. + * Enables the managed tunnel when the server entry loads, which the runtime then answers. The `sentryTanstackStart` + * Vite plugin calls this from the app's server entry, with its `tunnelRoute` and the base path Start routes on. * * `tunnelRoute` enables the managed tunnel on every server client the same way `serveTunnelIntegration` does, unless * that integration is set up too, and the tunnel is then served from the client's config like in every other SDK. * * @internal Only the code the Vite plugin generates calls this, so its parameters can change at any time. */ -export function wrapServerEntryWithSentryTunnel( - entry: T, - tunnelRoute?: ServerTunnelOptions, - basePath?: string, -): T { - // Runs when the server entry loads, before any request. Requests still carry Start's base path, so the tunnel is - // served under it. +export function setTunnelRoute(tunnelRoute?: ServerTunnelOptions, basePath?: string): void { + // Requests still carry Start's base path, so the tunnel is served under it. setTunnelDefaults({ tunnel: tunnelRoute, forward: forwardTunnelRequest, basePath }); - - return entry; } diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index f91c61e0d2e2..cc280e24b514 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -67,7 +67,7 @@ function hasRouteConflict(source: string, tunnelPath: string): boolean { } /** - * Tells the client SDK which tunnel to use. The server entry wrapper serves it. + * Tells the client SDK which tunnel to use. The runtime serves it. */ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { const { path } = normalizeTunnelRouteOptions(options); @@ -82,7 +82,7 @@ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boole config() { return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; }, - // The server entry wrapper answers POSTs to a fixed path before Start's routing, so an app route on that path + // The runtime answers POSTs to a fixed path before Start's routing, so an app route on that path // would silently lose them. transform(source, id) { if (path && /[\\/]routeTree\.gen\.ts$/.test(id) && hasRouteConflict(source, path)) { @@ -99,7 +99,7 @@ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boole const START_BASE_PATH_DEFINE = 'process.env.TSS_ROUTER_BASEPATH'; /** - * Reads the base path TanStack Start routes on and hands it to the client SDK and the server entry wrapper, so the + * Reads the base path TanStack Start routes on and hands it to the client SDK and the server entry, so the * managed tunnel is served under it without the user repeating it. Start resolves it from `router.basepath`, or from * Vite's `base`, and defines it for every environment. This runs after Start's own `config` hook to see that define. */ @@ -144,9 +144,8 @@ const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/(?:esm\/)?server\.[jt]s$/; /** - * Wraps the TanStack Start server entry so tunnel requests are answered before Start's own request middleware and - * routing. It does nothing unless the managed tunnel is enabled, either here via `tunnelRoute` or at runtime via - * `Sentry.init({ tunnel: true })`. + * Enables `tunnelRoute` when the TanStack Start server entry loads, under the base path Start routes on. The runtime + * then answers the tunnel requests before Start's own request middleware and routing. */ export function makeServerEntryTunnelPlugin( tunnelRoute: TunnelRouteOptions | undefined, @@ -190,10 +189,10 @@ export function makeServerEntryTunnelPlugin( const resolved = await this.resolve(SDK_PACKAGE, `${root}/package.json`, { skipSelf: true }); const sdk = resolved && !resolved.external ? resolved.id : SDK_PACKAGE; - return `import entry from ${original}; -import { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from ${JSON.stringify(sdk)}; + return `import { _INTERNAL_setTunnelRoute as setTunnelRoute } from ${JSON.stringify(sdk)}; export * from ${original}; -export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); +export { default } from ${original}; +setTunnelRoute(${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; }, }; diff --git a/packages/tanstackstart-react/test/server/setTunnelRoute.test.ts b/packages/tanstackstart-react/test/server/setTunnelRoute.test.ts new file mode 100644 index 000000000000..e8575146b42a --- /dev/null +++ b/packages/tanstackstart-react/test/server/setTunnelRoute.test.ts @@ -0,0 +1,38 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; +import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; +import { afterEach, describe, expect, it } from 'vitest'; +import { setTunnelRoute } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, ignoreSpans: [] }; + const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; + getCurrentScope().setClient(client); + if (managedTunnel) { + serveTunnelIntegration(managedTunnel).setup!(client); + } +} + +describe('setTunnelRoute', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetTunnelDefaults(); + }); + + it('enables `tunnelRoute` on the client like `serveTunnelIntegration` would, dropping its spans from the start', () => { + setClient(undefined); + const options = getCurrentScope().getClient()!.getOptions(); + + setTunnelRoute({}); + + expect(options._managedTunnel).toEqual({}); + expect( + (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( + getTunnelPath(DSN)!, + ), + ).toBe(true); + }); +}); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts deleted file mode 100644 index d373d8fc84be..000000000000 --- a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Client } from '@sentry/core'; -import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; -import { _INTERNAL_resetTunnelDefaults, serveTunnelIntegration } from '@sentry/core/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; - -const DSN = 'https://public@o1.ingest.sentry.io/1337'; -const TUNNEL_URL = `http://localhost:3000${getTunnelPath(DSN)}`; - -function makeEnvelopeRequest(url: string): Request { - const body = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; - - return new Request(url, { method: 'POST', body, headers: { 'content-length': String(body.length) } }); -} - -/** Sets a client, with `serveTunnelIntegration` set up when `managedTunnel` is given. */ -function setClient(managedTunnel: object | undefined): void { - const options = { dsn: DSN, ignoreSpans: [] }; - const client = { getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client; - getCurrentScope().setClient(client); - if (managedTunnel) { - serveTunnelIntegration(managedTunnel).setup!(client); - } -} - -describe('wrapServerEntryWithSentryTunnel', () => { - const appFetch = vi.fn(() => new Response('app')); - const sentryFetch = vi.fn(() => Promise.resolve(new Response('forwarded'))); - - beforeEach(() => { - vi.stubGlobal('fetch', sentryFetch); - }); - - afterEach(() => { - getCurrentScope().setClient(undefined); - _INTERNAL_resetTunnelDefaults(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - it('hands every request to the app when the managed tunnel is off', async () => { - setClient(undefined); - - const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); - - expect(await response.text()).toBe('app'); - expect(sentryFetch).not.toHaveBeenCalled(); - }); - - it('passes other requests and their options to the app', async () => { - setClient({}); - const request = makeEnvelopeRequest('http://localhost:3000/api/users'); - - await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, { context: 1 }); - - expect(appFetch).toHaveBeenCalledWith(request, { context: 1 }); - }); - - it("passes every argument to the app, like a Cloudflare Worker's `env` and `ctx`", async () => { - setClient({}); - const request = makeEnvelopeRequest('http://localhost:3000/api/users'); - const env = { E2E_TEST_DSN: DSN }; - const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; - - await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, env, ctx); - - expect(appFetch).toHaveBeenCalledWith(request, env, ctx); - }); - - it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { - class Worker { - public fetch(): Response { - return new Response('app'); - } - } - - expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); - }); - - it('enables `tunnelRoute` on the client like `serveTunnelIntegration` would, dropping its spans from the start', () => { - setClient(undefined); - const options = getCurrentScope().getClient()!.getOptions(); - - wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}); - - expect(options._managedTunnel).toEqual({}); - expect( - (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( - getTunnelPath(DSN)!, - ), - ).toBe(true); - }); -}); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 20008ede700b..110b1ccae40b 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -94,17 +94,17 @@ describe('server entry tunnel vite plugin', () => { ) as Promise; } - it('wraps the app server entry and the default entry', async () => { + it('sets the tunnel route from the app server entry and the default entry', async () => { const load = setup(); - expect(await load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined, "");'); + expect(await load(USER_ENTRY)).toContain('setTunnelRoute(undefined, "");'); expect(await load(`${USER_ENTRY}?sentry-tunnel-original`)).toBeNull(); expect(await load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( - 'wrapServerEntryWithSentryTunnel', + 'setTunnelRoute(', ); expect( await load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts'), - ).toContain('wrapServerEntryWithSentryTunnel'); + ).toContain('setTunnelRoute('); expect(await load('/app/src/routes/index.tsx')).toBeNull(); }); @@ -112,25 +112,22 @@ describe('server entry tunnel vite plugin', () => { const load = setup(); expect(await load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); + expect(await load(USER_ENTRY)).toContain(`export { default } from "${USER_ENTRY}?sentry-tunnel-original";`); }); it('passes the build-time options when `tunnelRoute` derives the path from the DSN', async () => { - expect(await setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {}, "");'); + expect(await setup(true)(USER_ENTRY)).toContain('setTunnelRoute({}, "");'); expect(await setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( - 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', + 'setTunnelRoute({"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', ); }); - it('serves a static `tunnelRoute` path from the wrapper too', async () => { - expect(await setup('/monitor')(USER_ENTRY)).toContain( - 'wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");', - ); + it('passes a static `tunnelRoute` path too', async () => { + expect(await setup('/monitor')(USER_ENTRY)).toContain('setTunnelRoute({"path":"/monitor"}, "");'); }); - it('passes the base path Start routes on to the wrapper', async () => { - expect(await setup(undefined, '/app')(USER_ENTRY)).toContain( - 'wrapServerEntryWithSentryTunnel(entry, undefined, "/app");', - ); + it('passes the base path Start routes on', async () => { + expect(await setup(undefined, '/app')(USER_ENTRY)).toContain('setTunnelRoute(undefined, "/app");'); }); it('imports a bundled SDK from where the app resolves it, which also works without hoisting', async () => { @@ -138,7 +135,7 @@ describe('server entry tunnel vite plugin', () => { const load = setup(undefined, '', resolve); expect(await load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( - `import { _INTERNAL_wrapServerEntryWithSentryTunnel as wrapServerEntryWithSentryTunnel } from "${SDK_ID}";`, + `import { _INTERNAL_setTunnelRoute as setTunnelRoute } from "${SDK_ID}";`, ); expect(resolve).toHaveBeenCalledWith('@sentry/tanstackstart-react', '/app/package.json', { skipSelf: true }); }); From e1272c3bcb8ae1e7258e100326472bbbca652cff Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 15:13:59 -0500 Subject: [PATCH 3/3] fix(sveltekit,astro,remix): Keep answering tunnel requests in the SDK's own request hook Where no runtime hook answers tunnel requests, like on Vercel Edge, the request hook these SDKs already run is the only place left to answer them. It's one call, and where a runtime hook exists it answers first. --- packages/astro/src/server/middleware.ts | 10 ++++- packages/astro/test/server/middleware.test.ts | 22 ++++++++++ packages/remix/src/v3/server/middleware.ts | 6 +++ packages/remix/test/v3/middleware.test.ts | 13 ++++++ .../sveltekit/src/server-common/handle.ts | 11 ++++- .../test/server-common/handle.test.ts | 41 +++++++++++++++++++ 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index e6a72f4b9dbe..410fe0c9a8be 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -26,7 +26,10 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; +import { + flushIfServerless, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, +} from '@sentry/core/server'; import { captureException, continueTrace, @@ -87,6 +90,11 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } + const tunnelResponse = handleServedTunnelRequest(ctx.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); // For static (prerendered) routes, we only want to inject the parametrized route meta tags diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index b205af292372..fd9f0dc2a139 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -2,6 +2,7 @@ import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } f import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; +import * as SentryCoreServer from '@sentry/core/server'; import * as SentryNode from '@sentry/node'; import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -33,6 +34,27 @@ const STATIC_REQUEST_CONTEXT = { url: new URL('https://myDomain.io/users/'), }; +describe('sentryMiddleware with the managed tunnel', () => { + it('answers tunnel requests without calling the rest of the middleware chain', async () => { + const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); + const tunnelResponse = new Response('forwarded'); + const matcherSpy = vi + .spyOn(SentryCoreServer, '_INTERNAL_handleServedTunnelRequest') + .mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); + + const response = await handleRequest()({ ...DYNAMIC_REQUEST_CONTEXT, request } as unknown as APIContext, next); + + expect(response).toBe(tunnelResponse); + expect(matcherSpy).toHaveBeenCalledWith(request); + expect(next).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); + matcherSpy.mockRestore(); + }); +}); + describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index f2ce59c48f01..4ca54f648383 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -10,6 +10,7 @@ import { updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; @@ -25,6 +26,11 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { + const tunnelResponse = handleServedTunnelRequest(context.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isolationScope = getIsolationScope(); isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) }); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 8a8e1e5db21b..e0f1866e78fd 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -40,6 +40,19 @@ describe('sentryRemixMiddleware', () => { handleServedTunnelRequest.mockReturnValue(undefined); }); + it('answers tunnel requests before route matching and the rest of the chain', async () => { + const tunnelResponse = new Response('forwarded'); + handleServedTunnelRequest.mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const context = contextFor('http://x/abc123'); + + const response = await sentryRemixMiddleware(matcherFor('/'))(context, next); + + expect(response).toBe(tunnelResponse); + expect(handleServedTunnelRequest).toHaveBeenCalledWith(context.request); + expect(next).not.toHaveBeenCalled(); + }); + it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { getTraceData.mockReturnValue({ 'sentry-trace': 'abc-def-1', baggage: 'sentry-trace_id=abc' }); const middleware = sentryRemixMiddleware(matcherFor('/users/:id')); diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 15d2a88413f4..38291b226679 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -19,7 +19,11 @@ import { withIsolationScope, filterCollectedUrl, } from '@sentry/core'; -import { flushIfServerless, _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; +import { + flushIfServerless, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_setTunnelDefaults as setTunnelDefaults, +} from '@sentry/core/server'; import { getTunnelBasePath } from '../common/tunnel'; import type { Handle, ResolveOptions } from './handleTypes'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -297,6 +301,11 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { + const tunnelResponse = handleServedTunnelRequest(input.event.request); + if (tunnelResponse) { + return tunnelResponse; + } + const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent; // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 6d3ddadc53ae..9e55877cfdad 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -4,6 +4,7 @@ import { getCapturedScopesOnSpan, getRootSpan, getSpanDescendants, spanIsSampled import * as SentryCore from '@sentry/core'; import { NodeClient, serveTunnelIntegration, setCurrentClient } from '@sentry/node'; import { _INTERNAL_resetTunnelDefaults } from '@sentry/core/server'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; import type { Handle } from '../../src/server-common/handleTypes'; import { redirect } from '@sveltejs/kit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -145,6 +146,16 @@ describe('sentryHandle with span streaming', () => { describe('sentryHandle with the managed tunnel', () => { const DSN = 'https://public@o1.ingest.sentry.io/1337'; + function makeTunnelRequest(path: string): Request { + const body = SentryCore.serializeEnvelope(SentryCore.createEnvelope({ dsn: DSN }, [])) as string; + + return new Request(`http://localhost:3000${path}`, { + method: 'POST', + body, + headers: { 'content-length': String(body.length) }, + }); + } + beforeEach(() => { vi.stubGlobal( 'fetch', @@ -160,6 +171,36 @@ describe('sentryHandle with the managed tunnel', () => { _INTERNAL_resetTunnelDefaults(); }); + it('answers tunnel requests without resolving the route', async () => { + const mockResolve = vi.fn(); + + const response = await sentryHandle()({ + event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), + resolve: mockResolve, + }); + + expect(await response.text()).toBe('forwarded'); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it('serves the tunnel under the base path `sentrySvelteKit()` set before the handle was created', async () => { + vi.stubGlobal(TUNNEL_BASE_PATH_GLOBAL, '/app'); + const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); + const handle = sentryHandle(); + + const underBasePath = await handle({ + event: mockEvent({ request: makeTunnelRequest(`/app${SentryCore.getTunnelPath(DSN)!}`), route: { id: null } }), + resolve: mockResolve, + }); + const atRoot = await handle({ + event: mockEvent({ request: makeTunnelRequest(SentryCore.getTunnelPath(DSN)!), route: { id: null } }), + resolve: mockResolve, + }); + + expect(await underBasePath.text()).toBe('forwarded'); + expect(await atRoot.text()).toBe('app'); + }); + it('resolves every other request', async () => { const mockResolve = vi.fn(() => Promise.resolve(new Response('app')));