From 465294617e97ec32ba09c852c71781e5bedcc0ba Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 12:03:39 -0400 Subject: [PATCH 1/4] feat(astro): Serve the managed tunnel from the Sentry middleware The Sentry middleware runs first and answers tunnel requests. The integration hands Astro's `base` and `trailingSlash: 'always'` to both SDKs, so `tunnel: true` follows Astro's routing without repeating them. --- .../astro-7/astro.config.mjs | 5 ++ .../test-applications/astro-7/package.json | 13 +++- .../astro-7/sentry.client.config.js | 2 +- .../astro-7/sentry.server.config.js | 2 +- .../astro-7/src/middleware.ts | 14 +++++ .../astro-7/tests/tunnel.test.ts | 43 +++++++++++++ packages/astro/src/client/sdk.ts | 2 + packages/astro/src/integration/index.ts | 15 +++-- packages/astro/src/integration/tunnel.ts | 32 ++++++++++ packages/astro/src/server/middleware.ts | 6 ++ packages/astro/src/server/sdk.ts | 2 + packages/astro/src/tunnel.ts | 18 ++++++ packages/astro/test/server/middleware.test.ts | 21 +++++++ packages/astro/test/tunnel.test.ts | 63 +++++++++++++++++++ 14 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts create mode 100644 packages/astro/src/integration/tunnel.ts create mode 100644 packages/astro/src/tunnel.ts create mode 100644 packages/astro/test/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs index 061db869d94b..49370ed1e18b 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; +// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel +// follows both. +const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL; + // https://astro.build/config export default defineConfig({ + ...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }), integrations: [ sentry({ debug: true, diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index 10618dfc3227..9ffc816a0524 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test db" + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -27,6 +29,13 @@ "extends": "../../package.json" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "astro-7 (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js index 83573d36d0be..c0f094f0dd1a 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js @@ -4,7 +4,7 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server integrations: [ Sentry.browserTracingIntegration({ beforeStartSpan: opts => { diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js index bc90470cef38..ae5ecf038b62 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js @@ -4,6 +4,6 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts new file mode 100644 index 000000000000..fefaf0abe0b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts @@ -0,0 +1,14 @@ +import { defineMiddleware } from 'astro:middleware'; + +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. +export const onRequest = defineMiddleware((context, next) => { + if ( + import.meta.env.PUBLIC_E2E_TEST_TUNNEL && + context.request.method === 'POST' && + context.request.headers.get('x-auth') !== 'ok' + ) { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts new file mode 100644 index 000000000000..96d07a6a5291 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; + +test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'astro-7', + span => + getSpanOp(span) === 'http.server' && + /\/docs\/[a-z0-9]{1,7}\/?$/.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/docs/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'astro-7', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '').startsWith('/docs/test-ssr'), + ); + await page.request.get('/docs/test-ssr/'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 14effd00cacb..bd05cbd24579 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { BrowserOptions } from '@sentry/browser'; import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser'; import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; +import { applyAstroTunnelOptions } from '../tunnel'; import { browserTracingIntegration } from './browserTracingIntegration'; import { createAstroRouteProvider } from './routeProvider'; @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'astro', ['astro', 'browser']); + applyAstroTunnelOptions(opts); return initBrowserSdk(opts); } diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 396413b64939..390d28ed92cc 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel'; import type { SentryOptions } from './types'; const PKG_NAME = '@sentry/astro'; @@ -128,15 +129,21 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { }); } + const tunnelOptions = getAstroTunnelOptions(config); + if (tunnelOptions) { + updateConfig({ vite: { plugins: [makeTunnelOptionsVitePlugin(tunnelOptions)] } }); + } + const tunnelOptionsImport = tunnelOptions ? TUNNEL_OPTIONS_IMPORT : ''; + if (sdkEnabled.client) { const pathToClientInit = clientInitPath ? path.resolve(clientInitPath) : findDefaultSdkInitFile('client'); if (pathToClientInit) { debug && logger.info(`Using ${pathToClientInit} for client init.`); - injectScript('page', buildSdkInitFileImportSnippet(pathToClientInit)); + injectScript('page', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToClientInit)); } else { debug && logger.info('Using default client init.'); - injectScript('page', buildClientSnippet(options || {})); + injectScript('page', tunnelOptionsImport + buildClientSnippet(options || {})); } } @@ -182,10 +189,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // Always inject the server config via `injectScript('page-ssr')`. // 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. - injectScript('page-ssr', buildSdkInitFileImportSnippet(pathToServerInit)); + injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit)); } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); - injectScript('page-ssr', buildServerSnippet(options || {})); + injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {})); } if (isCloudflareWorkers && command !== 'dev') { diff --git a/packages/astro/src/integration/tunnel.ts b/packages/astro/src/integration/tunnel.ts new file mode 100644 index 000000000000..d3f0bc2a3d5b --- /dev/null +++ b/packages/astro/src/integration/tunnel.ts @@ -0,0 +1,32 @@ +import type { TunnelOptions } from '@sentry/core'; +import type { AstroConfig } from 'astro'; +import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel'; +import type { VitePlugin } from './cloudflare'; + +const TUNNEL_OPTIONS_MODULE = 'virtual:@sentry/astro/tunnel-options'; +const RESOLVED_TUNNEL_OPTIONS_MODULE = `\0${TUNNEL_OPTIONS_MODULE}`; + +/** The tunnel options Astro's routing requires, or `undefined` if the defaults work. */ +export function getAstroTunnelOptions(config: Pick): TunnelOptions | undefined { + const basePath = config.base?.replace(/\/$/, ''); + const trailingSlash = config.trailingSlash === 'always'; + + return basePath || trailingSlash + ? { ...(basePath && { basePath }), ...(trailingSlash && { trailingSlash }) } + : undefined; +} + +/** Imported first in the injected init scripts, so the options are set before `Sentry.init` runs. */ +export const TUNNEL_OPTIONS_IMPORT = `import "${TUNNEL_OPTIONS_MODULE}";\n`; + +/** Serves the module that hands the tunnel options to the SDKs. */ +export function makeTunnelOptionsVitePlugin(tunnelOptions: TunnelOptions): VitePlugin { + return { + name: 'sentry-astro-tunnel-options', + resolveId: id => (id === TUNNEL_OPTIONS_MODULE ? RESOLVED_TUNNEL_OPTIONS_MODULE : null), + load: id => + id === RESOLVED_TUNNEL_OPTIONS_MODULE + ? `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};` + : null, + }; +} diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 2e88b7fc160a..5bbec51c77be 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -25,6 +25,7 @@ import { winterCGRequestToRequestData, filterCollectedUrl, filterCollectedUrlQuery, + handleTunnelRequestIfMatched, } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import { @@ -94,6 +95,11 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } + const tunnelResponse = handleTunnelRequestIfMatched(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 6ba2703bc994..aea532c3eaa5 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -2,6 +2,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; +import { applyAstroTunnelOptions } from '../tunnel'; /** * @@ -13,6 +14,7 @@ export function init(options: NodeOptions): NodeClient | undefined { }; applySdkMetadata(opts, 'astro', ['astro', 'node']); + applyAstroTunnelOptions(opts); opts.ignoreSpans = [ ...(opts.ignoreSpans || []), diff --git a/packages/astro/src/tunnel.ts b/packages/astro/src/tunnel.ts new file mode 100644 index 000000000000..bbccfbbc9471 --- /dev/null +++ b/packages/astro/src/tunnel.ts @@ -0,0 +1,18 @@ +import type { Options, TunnelOptions } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; + +/** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */ +export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; + +/** + * Serves the managed tunnel under the app's base path and with Astro's trailing slash policy, which Astro enforces + * before any middleware runs. Options the user set win. + */ +export function applyAstroTunnelOptions(options: Pick): void { + const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: TunnelOptions })[TUNNEL_OPTIONS_GLOBAL]; + if (!astroTunnelOptions || !options.tunnel || typeof options.tunnel === 'string') { + return; + } + + options.tunnel = { ...astroTunnelOptions, ...(options.tunnel === true ? {} : options.tunnel) }; +} diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 88a21e303f10..b8b264c61723 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -33,6 +33,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(SentryCore, 'handleTunnelRequestIfMatched') + .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/astro/test/tunnel.test.ts b/packages/astro/test/tunnel.test.ts new file mode 100644 index 000000000000..1752a3444dbe --- /dev/null +++ b/packages/astro/test/tunnel.test.ts @@ -0,0 +1,63 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin } from '../src/integration/tunnel'; +import { applyAstroTunnelOptions, TUNNEL_OPTIONS_GLOBAL } from '../src/tunnel'; + +const globalWithOptions = GLOBAL_OBJ as { __sentry_astro_tunnel_options?: unknown }; + +describe('getAstroTunnelOptions', () => { + it('maps `base` and `trailingSlash: always` onto tunnel options', () => { + expect(getAstroTunnelOptions({ base: '/docs/', trailingSlash: 'always' })).toEqual({ + basePath: '/docs', + trailingSlash: true, + }); + expect(getAstroTunnelOptions({ base: '/docs', trailingSlash: 'ignore' })).toEqual({ basePath: '/docs' }); + }); + + it('returns nothing for the defaults', () => { + expect(getAstroTunnelOptions({ base: '/', trailingSlash: 'ignore' })).toBeUndefined(); + }); +}); + +describe('makeTunnelOptionsVitePlugin', () => { + it('serves a module that sets the tunnel options global', () => { + const plugin = makeTunnelOptionsVitePlugin({ basePath: '/docs' }); + const resolved = (plugin.resolveId as unknown as (id: string) => string | null)( + 'virtual:@sentry/astro/tunnel-options', + ); + + expect((plugin.load as unknown as (id: string) => string | null)(resolved!)).toBe( + `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = {"basePath":"/docs"};`, + ); + }); +}); + +describe('applyAstroTunnelOptions', () => { + afterEach(() => { + delete globalWithOptions.__sentry_astro_tunnel_options; + }); + + it('adds the Astro routing options to the managed tunnel, letting user options win', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true }; + const fromTrue: Parameters[0] = { tunnel: true }; + const fromOptions: Parameters[0] = { tunnel: { basePath: '/other' } }; + + applyAstroTunnelOptions(fromTrue); + applyAstroTunnelOptions(fromOptions); + + expect(fromTrue.tunnel).toEqual({ basePath: '/docs', trailingSlash: true }); + expect(fromOptions.tunnel).toEqual({ basePath: '/other', trailingSlash: true }); + }); + + it('leaves URL tunnels and a disabled tunnel alone', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs' }; + const url: Parameters[0] = { tunnel: '/monitor' }; + const off: Parameters[0] = {}; + + applyAstroTunnelOptions(url); + applyAstroTunnelOptions(off); + + expect(url.tunnel).toBe('/monitor'); + expect(off.tunnel).toBeUndefined(); + }); +}); From cae5eaaba8ec301a186d58866db39bfaafe142f3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 23:32:30 -0400 Subject: [PATCH 2/4] fix(astro): Run the server init before the middleware so the tunnel works on a cold server --- .../astro-7/tests/tunnel.test.ts | 8 ++++++ packages/astro/src/integration/index.ts | 19 +++++++++----- .../src/integration/middleware/virtual.ts | 22 ++++++++++++++++ packages/astro/test/integration/index.test.ts | 25 ++++++++++++++++++- 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 packages/astro/src/integration/middleware/virtual.ts diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts index 96d07a6a5291..b45aac26311c 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts @@ -3,9 +3,17 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); +const DSN = 'http://public@localhost:3031/1337'; const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => { + // The first request this server sees, so no SSR page has run `Sentry.init` yet. + const coldTunnelResponse = await page.request.post('/docs/qpo967/', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: DSN })}\n`, + }); + expect(coldTunnelResponse.status()).toBe(200); + // The variant's auth middleware rejects every other POST. expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401); diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 390d28ed92cc..e10660163f36 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -7,6 +7,7 @@ import { createRequire } from 'module'; import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; +import { makeSentryMiddlewareVitePlugin, SENTRY_MIDDLEWARE_MODULE } from './middleware/virtual'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel'; import type { SentryOptions } from './types'; @@ -181,18 +182,23 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } } + let serverInitSnippet = ''; if (sdkEnabled.server) { const pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server'); if (pathToServerInit) { debug && logger.info(`Using ${pathToServerInit} for server init.`); - // Always inject the server config via `injectScript('page-ssr')`. - // 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. - injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit)); + serverInitSnippet = tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit); } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); - injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {})); + serverInitSnippet = tunnelOptionsImport + buildServerSnippet(options || {}); + } + + // Always inject the server config via `injectScript('page-ssr')`. + // 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) { + injectScript('page-ssr', serverInitSnippet); } if (isCloudflareWorkers && command !== 'dev') { @@ -239,9 +245,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false; if (isSSR && shouldAddMiddleware) { + updateConfig({ vite: { plugins: [makeSentryMiddlewareVitePlugin(serverInitSnippet)] } }); addMiddleware({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: SENTRY_MIDDLEWARE_MODULE, }); } }, diff --git a/packages/astro/src/integration/middleware/virtual.ts b/packages/astro/src/integration/middleware/virtual.ts new file mode 100644 index 000000000000..b5122a4a3e61 --- /dev/null +++ b/packages/astro/src/integration/middleware/virtual.ts @@ -0,0 +1,22 @@ +import type { VitePlugin } from '../cloudflare'; + +/** + * The middleware entrypoint the integration registers instead of `@sentry/astro/middleware`. + */ +export const SENTRY_MIDDLEWARE_MODULE = 'virtual:@sentry/astro/middleware'; +const RESOLVED_SENTRY_MIDDLEWARE_MODULE = `\0${SENTRY_MIDDLEWARE_MODULE}`; + +/** + * Serves the Sentry middleware behind the server init. `page-ssr` scripts only run once an SSR page loads, so without + * this a request that renders no page, like a tunnel request on a cold serverless instance, would find no client. + */ +export function makeSentryMiddlewareVitePlugin(serverInitSnippet: string): VitePlugin { + return { + name: 'sentry-astro-middleware', + resolveId: id => (id === SENTRY_MIDDLEWARE_MODULE ? RESOLVED_SENTRY_MIDDLEWARE_MODULE : null), + load: id => + id === RESOLVED_SENTRY_MIDDLEWARE_MODULE + ? `${serverInitSnippet}\nexport { onRequest } from "@sentry/astro/middleware";\n` + : null, + }; +} diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 42e9af789247..1074f2f51032 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -680,10 +680,33 @@ describe('sentryAstro integration', () => { expect(addMiddleware).toHaveBeenCalledTimes(1); expect(addMiddleware).toHaveBeenCalledWith({ order: 'pre', - entrypoint: '@sentry/astro/middleware', + entrypoint: 'virtual:@sentry/astro/middleware', }); }); + it('runs the server init before the middleware, so requests that render no page find a client', async () => { + const integration = sentryAstro({}); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + // @ts-expect-error - we only need to pass what we actually use + config: { output: 'server' }, + addMiddleware: vi.fn(), + updateConfig, + injectScript, + }); + + const plugin = updateConfig.mock.calls + .flatMap(([config]) => config.vite?.plugins ?? []) + .find(plugin => plugin.name === 'sentry-astro-middleware'); + const resolvedId = plugin.resolveId('virtual:@sentry/astro/middleware'); + const serverInit = injectScript.mock.calls.find(([stage]) => stage === 'page-ssr')?.[1]; + + expect(plugin.load(resolvedId)).toBe(`${serverInit}\nexport { onRequest } from "@sentry/astro/middleware";\n`); + }); + it.each([{ output: 'static' }, { output: undefined }])( "doesn't add middleware if in static mode (config %s)", async (config: any) => { From 23cb7a1a47d27e28d54c007fea054271b0c0f87c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:08:51 -0400 Subject: [PATCH 3/4] fix(astro): Serve a relative `tunnel` path under the base path too --- packages/astro/src/tunnel.ts | 8 ++------ packages/astro/test/tunnel.test.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/astro/src/tunnel.ts b/packages/astro/src/tunnel.ts index bbccfbbc9471..e24da0785fdf 100644 --- a/packages/astro/src/tunnel.ts +++ b/packages/astro/src/tunnel.ts @@ -1,5 +1,5 @@ import type { Options, TunnelOptions } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; /** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */ export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; @@ -10,9 +10,5 @@ export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; */ export function applyAstroTunnelOptions(options: Pick): void { const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: TunnelOptions })[TUNNEL_OPTIONS_GLOBAL]; - if (!astroTunnelOptions || !options.tunnel || typeof options.tunnel === 'string') { - return; - } - - options.tunnel = { ...astroTunnelOptions, ...(options.tunnel === true ? {} : options.tunnel) }; + options.tunnel = applyTunnelDefaults(options.tunnel, astroTunnelOptions); } diff --git a/packages/astro/test/tunnel.test.ts b/packages/astro/test/tunnel.test.ts index 1752a3444dbe..9aaf293c3e11 100644 --- a/packages/astro/test/tunnel.test.ts +++ b/packages/astro/test/tunnel.test.ts @@ -49,15 +49,24 @@ describe('applyAstroTunnelOptions', () => { expect(fromOptions.tunnel).toEqual({ basePath: '/other', trailingSlash: true }); }); + it('serves a relative tunnel path with the Astro routing options too', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true }; + const options: Parameters[0] = { tunnel: '/monitor' }; + + applyAstroTunnelOptions(options); + + expect(options.tunnel).toEqual({ basePath: '/docs', trailingSlash: true, path: '/monitor' }); + }); + it('leaves URL tunnels and a disabled tunnel alone', () => { globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs' }; - const url: Parameters[0] = { tunnel: '/monitor' }; + const url: Parameters[0] = { tunnel: 'https://example.com/tunnel' }; const off: Parameters[0] = {}; applyAstroTunnelOptions(url); applyAstroTunnelOptions(off); - expect(url.tunnel).toBe('/monitor'); + expect(url.tunnel).toBe('https://example.com/tunnel'); expect(off.tunnel).toBeUndefined(); }); }); From 941ca1d089766136480b195925411b2e879229a6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:51:29 -0400 Subject: [PATCH 4/4] test(astro): Check the managed tunnel under `astro dev` too --- .../e2e-tests/test-applications/astro-7/package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index 9ffc816a0524..8a4fc043ce10 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -13,7 +13,8 @@ "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test db", "test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", - "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel" + "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -35,6 +36,11 @@ "build-command": "pnpm test:build:tunnel", "assert-command": "pnpm test:assert:tunnel", "label": "astro-7 (tunnel)" + }, + { + "label": "astro-7 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" } ] }