Skip to content
Merged
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 @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/repeated-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
20 changes: 17 additions & 3 deletions packages/astro/src/integration/serverInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`) {
Expand All @@ -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 };
},
};
}
12 changes: 11 additions & 1 deletion packages/astro/src/server/sdk.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<NodeClient>();
if (existingClient) {
DEBUG_BUILD && debug.log('SDK already initialized');

return existingClient;
}

const opts = {
...options,
};
Expand Down
14 changes: 3 additions & 11 deletions packages/nitro/src/runtime/plugins/server.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Comment thread
logaretm marked this conversation as resolved.
Resolved
// 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);
});
29 changes: 4 additions & 25 deletions packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 5 additions & 14 deletions packages/nuxt/src/runtime/plugins/tunnel.server.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion packages/nuxt/src/vite/nitroServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Public tunnel route helper removed

Medium Severity

createSentryTunnelRoutes was a named export from @sentry/react-router and @sentry/react-router/cloudflare. This change deletes that public function and replaces it with _INTERNAL_setTunnelBasePath, with no deprecation and no changelog note. Flagged because the review rules require deprecation or a migration callout before removing a publicly exported function.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 53c1064. Configure here.


// React Router instrumentation API support
export {
Expand Down
68 changes: 5 additions & 63 deletions packages/react-router/src/server/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,12 @@
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__';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tunnel missing on serverless adapters

High Severity

These frameworks now only call setTunnelDefaults and no longer intercept the request. Runtime hooks cover node:http, Bun.serve, Deno.serve, Cloudflare withSentry, and Lambda preload. Vercel and Netlify serverless adapters export a fetch handler, so managed tunnel POSTs fall through to the app. SvelteKit, Astro, and Remix still have a cheap fallback; React Router, TanStack Start, and Nitro-based apps do not.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 53c1064. Configure here.

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.
* `sentryReactRouter()` calls it from the server build.
*
* @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<string, Record<string, unknown>> {
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 });

// `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<string, unknown> {
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' } }),
},
};
}
28 changes: 28 additions & 0 deletions packages/react-router/src/vite/makeTunnelBasePathPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Plugin } from 'vite';

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 makeTunnelBasePathPlugin(): Plugin {
return {
name: 'sentry-react-router-tunnel-base-path',
enforce: 'post',

transform(code, id, options) {
if (!options?.ssr || !id.includes(SERVER_BUILD_MODULE_ID)) {
return null;
}

return {
code: `${code}
import { _INTERNAL_setTunnelBasePath as __sentrySetTunnelBasePath } from '@sentry/react-router';
__sentrySetTunnelBasePath(typeof basename === 'string' ? basename : undefined);
`,
map: null,
};
},
};
}
30 changes: 0 additions & 30 deletions packages/react-router/src/vite/makeTunnelRoutePlugin.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/react-router/src/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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