Repository navigation
feat(astro): Serve the managed tunnel from the Sentry middleware #25419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
87e9267
b444dca
36fcbad
ed25b86
12c1dc4
e487722
d61e095
c353e9f
2213eb8
4b6743e
44f16cc
2750314
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { getSpanOp, watchForTunnelSpan } from '@sentry-internal/test-utils'; | ||
|
|
||
| test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); | ||
|
|
||
| const APP_NAME = 'astro-6-cf-workers'; | ||
| const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; | ||
|
|
||
| test('answers the managed tunnel under the base path, without tunnel spans', async ({ page }) => { | ||
| const raceTunnelSpan = watchForTunnelSpan( | ||
| APP_NAME, | ||
| span => | ||
| getSpanOp(span) === 'http.server' && | ||
| /\/docs\/[a-z0-9]{1,7}\/?$/.test( | ||
| String(span.attributes['url.path']?.value ?? span.attributes['url.full']?.value ?? span.name), | ||
| ), | ||
| ); | ||
| 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(tunnelResponse.headers()['x-sentry-tunnel']).toBeDefined(); | ||
| expect( | ||
| await raceTunnelSpan( | ||
| span => getSpanOp(span) === 'http.server' && span.is_segment && span.name.includes('test-ssr'), | ||
| () => page.request.get('/docs/test-ssr/'), | ||
| ), | ||
| ).toBe('anchor'); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { isServerSpanAt, watchForTunnelSpan } 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}\/$/; | ||
|
|
||
| // First in the file, while the server has only served prerendered pages, so no SSR page has run `Sentry.init` yet. | ||
| test('answers the managed tunnel when the first page is prerendered', async ({ page }) => { | ||
| const tunnelResponsePromise = page.waitForResponse( | ||
| response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), | ||
| ); | ||
|
|
||
| await page.goto('/docs/test-static/'); | ||
|
|
||
| expect((await tunnelResponsePromise).status()).toBe(200); | ||
| }); | ||
|
|
||
| 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 raceTunnelSpan = watchForTunnelSpan('astro-7', isServerSpanAt(/\/docs\/[a-z0-9]{1,7}\/?$/)); | ||
| const tunnelResponsePromise = page.waitForResponse( | ||
| response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), | ||
| ); | ||
|
|
||
| await page.goto('/docs/'); | ||
|
|
||
| expect((await tunnelResponsePromise).status()).toBe(200); | ||
| expect(await raceTunnelSpan(isServerSpanAt(/^\/docs\/test-ssr/), () => page.request.get('/docs/test-ssr/'))).toBe( | ||
| 'anchor', | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,9 @@ import { createRequire } from 'module'; | |
| import * as path from 'path'; | ||
| import type { VitePlugin } from './cloudflare'; | ||
| import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; | ||
| import { makeServerInitVitePlugin, SENTRY_MIDDLEWARE_MODULE, SERVER_INIT_IMPORT } from './serverInit'; | ||
| 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 +130,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 || {})); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -174,18 +182,24 @@ 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', buildSdkInitFileImportSnippet(pathToServerInit)); | ||
| serverInitSnippet = tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit); | ||
| } else if (!usesCloudflareVitePlugin) { | ||
| debug && logger.info('Using default server init.'); | ||
| injectScript('page-ssr', 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) { | ||
| updateConfig({ vite: { plugins: [makeServerInitVitePlugin(serverInitSnippet)] } }); | ||
| injectScript('page-ssr', SERVER_INIT_IMPORT); | ||
| } | ||
|
|
||
| if (isCloudflareWorkers && command !== 'dev') { | ||
|
|
@@ -234,7 +248,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { | |
| if (isSSR && shouldAddMiddleware) { | ||
| addMiddleware({ | ||
| order: 'pre', | ||
| entrypoint: '@sentry/astro/middleware', | ||
| entrypoint: serverInitSnippet ? SENTRY_MIDDLEWARE_MODULE : '@sentry/astro/middleware', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before this PR, The PR also swaps the public middleware entry for a virtual one ( Suggestion: update or remove the stale comment and TODO in |
||
| }); | ||
| } | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import type { VitePlugin } from './cloudflare'; | ||
|
|
||
| const SERVER_INIT_MODULE = 'virtual:@sentry/astro/server-init'; | ||
|
|
||
| /** | ||
| * The middleware entrypoint the integration registers instead of `@sentry/astro/middleware` when it injects the init. | ||
| */ | ||
| export const SENTRY_MIDDLEWARE_MODULE = 'virtual:@sentry/astro/middleware'; | ||
|
|
||
| /** | ||
| * The `page-ssr` script that runs the server init. | ||
| */ | ||
| 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. | ||
| */ | ||
| export function makeServerInitVitePlugin(serverInitSnippet: string): VitePlugin { | ||
| return { | ||
| name: 'sentry-astro-server-init', | ||
| resolveId: id => (id === SERVER_INIT_MODULE || id === SENTRY_MIDDLEWARE_MODULE ? `\0${id}` : null), | ||
| load: id => { | ||
| if (id === `\0${SERVER_INIT_MODULE}`) { | ||
| return serverInitSnippet; | ||
| } | ||
|
|
||
| return id === `\0${SENTRY_MIDDLEWARE_MODULE}` | ||
| ? `${SERVER_INIT_IMPORT}export { onRequest } from "@sentry/astro/middleware";\n` | ||
| : null; | ||
| }, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import type { BrowserTunnelOptions } from '@sentry/core'; | ||
| import type { AstroConfig } from 'astro'; | ||
| import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel'; | ||
| import { WORKER_ENTRY_IDS, 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<AstroConfig, 'base' | 'trailingSlash'>, | ||
| ): BrowserTunnelOptions | 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. On the server it goes through core's managed tunnel | ||
| * defaults, because the server client isn't always `@sentry/astro`'s: on Cloudflare Workers it's `@sentry/cloudflare`'s, | ||
| * created per request. The Worker entry imports the module first, so the defaults are set before any client exists. | ||
| */ | ||
| export function makeTunnelOptionsVitePlugin(tunnelOptions: BrowserTunnelOptions): VitePlugin { | ||
| return { | ||
| name: 'sentry-astro-tunnel-options', | ||
| resolveId: id => (id === TUNNEL_OPTIONS_MODULE ? RESOLVED_TUNNEL_OPTIONS_MODULE : null), | ||
| load: (id, options) => { | ||
| if (id !== RESOLVED_TUNNEL_OPTIONS_MODULE) { | ||
| return null; | ||
| } | ||
|
|
||
| return options?.ssr | ||
| ? `import { _INTERNAL_setManagedTunnelDefaults } from '@sentry/astro/middleware';\n_INTERNAL_setManagedTunnelDefaults(${JSON.stringify({ basePath: tunnelOptions.basePath })});` | ||
| : `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};`; | ||
| }, | ||
| transform: (code, id) => | ||
| WORKER_ENTRY_IDS.some(entryId => id.includes(entryId)) ? TUNNEL_OPTIONS_IMPORT + code : null, | ||
| }; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.