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
f0d1485
5794016
e55ad2f
2abe62e
3007078
fce4c89
02724aa
e4f26cd
58253e8
b5d636a
a8d1cd6
fe531e2
8c3a1d0
5f52994
aa9764b
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 |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| import { defineCloudflareOptions } from '@sentry/cloudflare'; | ||
| import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudflare'; | ||
|
|
||
| export default defineCloudflareOptions(env => ({ | ||
| dsn: env.E2E_TEST_DSN, | ||
| environment: 'qa', | ||
| tracesSampleRate: 1.0, | ||
| tunnel: 'http://localhost:3031/', // proxy server | ||
| integrations: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? [serveTunnelIntegration()] : [], | ||
| debug: true, | ||
| })); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| 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( | ||
| 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.