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 @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';

// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel
// follows both on Workers too.
const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL;

// https://astro.build/config
export default defineConfig({
...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }),
integrations: [
sentry({
debug: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export default defineCloudflareOptions(env => ({
dsn: env.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,
}));
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"start": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030",
"test:build": "pnpm install && pnpm build",
"test:assert": "TEST_ENV=production playwright test",
"cf-typegen": "wrangler types --include-runtime=false"
"cf-typegen": "wrangler types --include-runtime=false",
"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 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel"
},
"dependencies": {
"@astrojs/cloudflare": "^13.0.2",
Expand All @@ -24,5 +26,14 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build:tunnel",
"assert-command": "pnpm test:assert:tunnel",
"label": "astro-6-cf-workers (tunnel)"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
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),
Comment thread
logaretm marked this conversation as resolved.
),
);
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
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions dev-packages/e2e-tests/test-applications/astro-7/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 sh -c 'TEST_ENV=production playwright test tunnel && 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": {
Expand All @@ -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)"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
14 changes: 14 additions & 0 deletions dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts
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',
);
});
2 changes: 2 additions & 0 deletions packages/astro/src/client/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined {
};

applySdkMetadata(opts, 'astro', ['astro', 'browser']);
applyAstroTunnelOptions(opts);

return initBrowserSdk(opts);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/integration/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function sentryCloudflareNodeWarningPlugin(): VitePlugin {

// The Worker entry module: `@astrojs/cloudflare` v12 builds its own virtual entry, and v13+ builds
// through `@cloudflare/vite-plugin`, whose entry ends in `export default mod.default ?? {};`.
const WORKER_ENTRY_IDS = ['astrojs-ssr-virtual-entry', 'virtual:cloudflare/worker-entry'];
export const WORKER_ENTRY_IDS = ['astrojs-ssr-virtual-entry', 'virtual:cloudflare/worker-entry'];

/**
* A Vite plugin that ensures the Sentry server config is loaded at the
Expand Down
30 changes: 22 additions & 8 deletions packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 || {}));
}
}

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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',
});
}
},
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/integration/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { handleRequest } from '../../server/middleware';

// The one `@sentry/astro` entry that resolves to server code on every runtime, so the integration's virtual module
// imports it from here.
export { _INTERNAL_setManagedTunnelDefaults } from '@sentry/core/server';

type MiddlewareNext = () => Promise<Response>;
type MiddlewareHandler = (ctx: unknown, next: MiddlewareNext) => Promise<Response> | Response | Promise<void> | void;

Expand Down
34 changes: 34 additions & 0 deletions packages/astro/src/integration/serverInit.ts
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;
},
};
}
45 changes: 45 additions & 0 deletions packages/astro/src/integration/tunnel.ts
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,
};
}
Loading
Loading