Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
};
}
10 changes: 1 addition & 9 deletions packages/astro/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/astro/src/server/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
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';

/**
*
* @param options
*/
export function init(options: NodeOptions): NodeClient | 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
22 changes: 0 additions & 22 deletions packages/astro/test/server/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');

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,21 +1,13 @@
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';
import { captureErrorHook } from '../hooks/captureErrorHook';

export default definePlugin(nitroApp => {
nitroApp.hooks.hook('error', captureErrorHook);

// 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);
});
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
67 changes: 6 additions & 61 deletions packages/react-router/src/server/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>> {
// 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' } }),
},
};
return {};
}
4 changes: 2 additions & 2 deletions packages/react-router/src/vite/makeTunnelRoutePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading