Skip to content
Merged
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
@@ -0,0 +1,18 @@
services:
db:
image: mysql:8.0
restart: always
container_name: e2e-tests-astro-6-cf-workers-mysql
# The `mysql` 2.x driver doesn't speak MySQL 8's default
# `caching_sha2_password` auth, so force the legacy plugin.
command: ['--default-authentication-plugin=mysql_native_password']
ports:
- '3306:3306'
environment:
MYSQL_ROOT_PASSWORD: docker
healthcheck:
test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker']
interval: 2s
timeout: 3s
retries: 30
start_period: 10s
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default async function globalSetup() {
// Start MySQL via Docker Compose. `--wait` blocks until the healthcheck in
// docker-compose.yml passes, so the worker can connect on the first request.
execSync('docker compose up -d --wait', {
cwd: __dirname,
stdio: 'inherit',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default async function globalTeardown() {
execSync('docker compose down --volumes', {
cwd: __dirname,
stdio: 'inherit',
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@sentry/astro": "file:../../packed/sentry-astro-packed.tgz",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"astro": "^6.0.0",
"mysql": "2.18.1",
"wrangler": "^4.72.0"
},
"volta": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ if (!testEnv) {
throw new Error('No test env defined');
}

const config = getPlaywrightConfig({
startCommand: 'pnpm start',
});
const config = getPlaywrightConfig(
{
startCommand: 'pnpm start',
},
{
globalSetup: './global-setup.mjs',
globalTeardown: './global-teardown.mjs',
},
);

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import mysql from 'mysql';

// The `@sentry/astro` orchestrion transform injects the `orchestrion:mysql:query` diagnostics
// channel into the bundled `mysql` package at build time. On Cloudflare Workers the transform also
// registers the matching subscriber factory on the global marker, which `@sentry/cloudflare` reads
// in the `withSentry` wrap — so these queries produce `db` spans with no OTel require-hook, which
// wouldn't work in workerd anyway.
export async function GET() {
// The connection is created inside the handler: workerd forbids I/O in global scope, and mysql
// opens its socket lazily on the first query. Explicit host/port because workerd's default
// resolution differs from Node's.
const connection = mysql.createConnection({
host: '127.0.0.1',
port: 3306,
user: 'root',
password: 'docker',
});

// Swallow connection-level errors so a socket hiccup doesn't become an uncaught exception that
// fails the request unrelated to the spans.
connection.on('error', () => {
// no-op
});

try {
// The second query is NESTED inside the first's callback. mysql dispatches that callback from
// its socket data handler (a fresh async context), so the nested query's span only lands on this
// request's http.server transaction if the channel subscriber restored the parent span across
// that async boundary.
await new Promise<void>((resolve, reject) => {
connection.query('SELECT 1 + 1 AS solution', err1 => {
if (err1) return reject(err1);
connection.query('SELECT NOW()', err2 => {
if (err2) return reject(err2);
resolve();
});
});
});
return new Response(JSON.stringify({ status: 'ok' }), { headers: { 'content-type': 'application/json' } });
} finally {
connection.end();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => {
const transactionPromise = waitForTransaction('astro-6-cf-workers', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
(transactionEvent.spans?.some(span => span.op === 'db') ?? false)
);
});

const res = await request.get('/db-mysql');
expect(res.status()).toBe(200);

const transactionEvent = await transactionPromise;
const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db');

const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
expect(firstQuery).toBeDefined();
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql');
expect(firstQuery!.data?.['db.system']).toBe('mysql');
expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution');
expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1');
expect(firstQuery!.data?.['net.peer.port']).toBe(3306);
expect(firstQuery!.data?.['db.user']).toBe('root');
});

test('a nested query lands on the same transaction (async context restored)', async ({ request }) => {
const transactionPromise = waitForTransaction('astro-6-cf-workers', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
(transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
);
});

const res = await request.get('/db-mysql');
expect(res.status()).toBe(200);

const transactionEvent = await transactionPromise;
const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description);
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
expect(descriptions).toContain('SELECT NOW()');
});
12 changes: 11 additions & 1 deletion packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,23 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
// `mysql`, `ioredis`) get `diagnostics_channel` publishers injected into the SSR bundle at
// build time, with no manual plugin setup. The plugin opts out internally when
// `buildTimeInstrumentation` is `false`.
// TODO: Cloudflare/workerd needs different wiring — skipped for now.
if (sdkEnabled.server && !isCloudflare) {
updateConfig({
vite: {
plugins: [sentryOrchestrionPlugin({ buildTimeInstrumentation }) as VitePlugin],
},
});
} else if (sdkEnabled.server && isCloudflareWorkers) {
// On Cloudflare Workers, subscribers are wired via a build-time marker the SDK reads at
// runtime (through the `withSentry` wrap added below). Cloudflare Pages is skipped: it gets
// no `withSentry` wrap, so there'd be nothing to read the marker.
updateConfig({
vite: {
plugins: [
sentryOrchestrionPlugin({ buildTimeInstrumentation, injectChannelSubscribers: true }) as VitePlugin,
],
},
});
}

if (isCloudflare) {
Expand Down
64 changes: 57 additions & 7 deletions packages/astro/test/integration/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type * as FsModule from 'fs';
import type { AstroConfig, AstroIntegrationLogger } from 'astro';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { _getUpdatedSourceMapSettings, sentryAstro } from '../../src/integration';
Expand All @@ -12,11 +13,14 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({

// Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in).
// Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant.
const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({
name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
}));
const orchestrionVite = vi.fn(
(options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => ({
name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
}),
);
vi.mock('@sentry/server-utils/orchestrion/vite', () => ({
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options),
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) =>
orchestrionVite(options),
}));

// The cloudflare adapter path resolves `@sentry/cloudflare` via `createRequire` and calls
Expand All @@ -30,6 +34,22 @@ vi.mock('module', async requireActual => {
};
});

// `isCloudflarePages()` probes for a wrangler config with `pages_build_output_dir`. By default no
// such file exists (Workers); the Pages test flips `wranglerPagesConfig` to a Pages config.
let wranglerPagesConfig: string | undefined;
vi.mock('fs', async requireActual => {
const actual = await requireActual<typeof FsModule>();
return {
...actual,
existsSync: (p: unknown) =>
wranglerPagesConfig !== undefined && String(p).endsWith('wrangler.jsonc') ? true : actual.existsSync(p as string),
readFileSync: (p: unknown, ...rest: unknown[]) =>
wranglerPagesConfig !== undefined && String(p).endsWith('wrangler.jsonc')
? wranglerPagesConfig
: (actual.readFileSync as (...args: unknown[]) => string)(p, ...rest),
};
});

process.env = {
...process.env,
SENTRY_AUTH_TOKEN: 'my-token',
Expand Down Expand Up @@ -431,7 +451,7 @@ describe('sentryAstro integration', () => {
});
});

it("doesn't add the orchestrion plugin for the cloudflare adapter", async () => {
it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare workers adapter', async () => {
const integration = sentryAstro({});

const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig;
Expand All @@ -445,14 +465,44 @@ describe('sentryAstro integration', () => {
config: cloudflareConfig,
});

expect(orchestrionVite).not.toHaveBeenCalled();
expect(updateConfig).not.toHaveBeenCalledWith({
// No wrangler config with `pages_build_output_dir` is present, so this resolves as Workers.
expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true }));
expect(updateConfig).toHaveBeenCalledWith({
vite: {
plugins: [{ name: 'sentry-orchestrion-vite' }],
},
});
});

it("doesn't add the orchestrion plugin for the cloudflare pages adapter", async () => {
// Simulate a Pages project: a wrangler config containing `pages_build_output_dir`.
wranglerPagesConfig = '{ "pages_build_output_dir": "./dist" }';

try {
const integration = sentryAstro({});
const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig;

expect(integration.hooks['astro:config:setup']).toBeDefined();
// @ts-expect-error - the hook exists and we only need to pass what we actually use
await integration.hooks['astro:config:setup']({
...baseConfigHookObject,
updateConfig,
injectScript,
config: cloudflareConfig,
});

// Pages has no `withSentry` wrap to read the marker, so orchestrion stays off there.
expect(orchestrionVite).not.toHaveBeenCalled();
expect(updateConfig).not.toHaveBeenCalledWith({
vite: {
plugins: [{ name: 'sentry-orchestrion-vite' }],
},
});
} finally {
wranglerPagesConfig = undefined;
}
});

it("doesn't warn about deprecated options when `buildTimeInstrumentation` is set", async () => {
const integration = sentryAstro({ buildTimeInstrumentation: false });

Expand Down
Loading