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-sveltekit-cloudflare-pages-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 @@ -15,10 +15,12 @@
"test:assert": "pnpm run test:e2e"
},
"dependencies": {
"@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz"
"@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz",
"mysql": "2.18.1"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@sveltejs/adapter-cloudflare": "^5.0.3",
"@sveltejs/kit": "2.69.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { defineConfig } from '@playwright/test';
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

export default defineConfig({
webServer: {
command: 'pnpm run build && pnpm run preview',
// `vite build` (where the Sentry SvelteKit plugin's orchestrion transform runs) produces the
// worker; `pnpm preview` (`wrangler pages dev`) serves the built output. `globalSetup` spins up
// the MySQL container the worker connects to.
const config = getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 4173,
},
{
globalSetup: './global-setup.mjs',
globalTeardown: './global-teardown.mjs',
},
);

testDir: 'tests',
});
export default config;
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const handle = sequence(
initCloudflareSentryHandle({
traceLifecycle: 'static',
dsn: E2E_TEST_DSN,
tunnel: 'http://localhost:3031/', // proxy server
tracesSampleRate: 1.0,
}),
sentryHandle(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import mysql from 'mysql';

// The `@sentry/sveltekit` orchestrion transform injects the `orchestrion:mysql:query`
// diagnostics channel into the bundled `mysql` package at build time. On Cloudflare the
// transform also registers the matching subscriber factory on the global marker, which
// `@sentry/cloudflare` reads in `wrapRequestHandler` — so these queries produce `db` spans
// with no OTel require-hook, which wouldn't work in workerd anyway.
export const GET = async () => {
// 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 json({ status: 'ok' });
} finally {
connection.end();
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'sveltekit-cloudflare-pages',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 ({ baseURL }) => {
// The `orchestrion:mysql:query` channel is injected into the bundled `mysql` package at
// build time by `@sentry/sveltekit`, which — because this app uses the Cloudflare adapter —
// also registers the subscriber factory on the global marker that `@sentry/cloudflare` reads
// in `wrapRequestHandler`. The query below therefore produces a `db` span on the request's
// http.server transaction, with no OTel require-hook (which wouldn't work in workerd).
const transactionPromise = waitForTransaction('sveltekit-cloudflare-pages', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
(transactionEvent.spans?.some(span => span.op === 'db') ?? false)
);
});

const res = await fetch(`${baseURL}/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 ({ baseURL }) => {
// The second query runs inside the first query's callback — i.e. across mysql's async
// socket-callback dispatch. Both spans appearing on the SAME http.server transaction proves
// the channel subscriber restored the parent span across that async boundary (otherwise the
// nested query would start its own trace and never join this transaction).
const transactionPromise = waitForTransaction('sveltekit-cloudflare-pages', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
(transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
);
});

const res = await fetch(`${baseURL}/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()');
});
11 changes: 7 additions & 4 deletions packages/sveltekit/src/vite/sentryVitePlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
);
}

// TODO: Cloudflare needs different wiring
if (mergedOptions.adapter !== 'cloudflare') {
sentryPlugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: mergedOptions.buildTimeInstrumentation }));
}
sentryPlugins.push(
sentryOrchestrionPlugin({
buildTimeInstrumentation: mergedOptions.buildTimeInstrumentation,
// On Cloudflare, subscribers are wired via a build-time marker the SDK reads at runtime;
...(mergedOptions.adapter === 'cloudflare' ? { injectChannelSubscribers: true } : {}),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Shouldn't we consolidate injectChannelSubscribers and buildTimeInstrumentation into one?

(not important for this PR though)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, buildTimeInstrumentation is just the global opt-out and injectChannelSubscribers is changing how we register the subscribers. afaik we do not unify these two?

}),
);
Comment thread
cursor[bot] marked this conversation as resolved.

const sentryVitePluginsOptions = generateVitePluginOptions(mergedOptions);

Expand Down
27 changes: 18 additions & 9 deletions packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ vi.mock('fs', async () => {

// 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),
}));

vi.spyOn(console, 'log').mockImplementation(() => {
Expand Down Expand Up @@ -109,13 +112,19 @@ describe('sentrySvelteKit()', () => {
expect(pluginNames).not.toContain('sentry-orchestrion-vite');
});

it("doesn't add the orchestrion plugin for the cloudflare adapter", async () => {
it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare adapter', async () => {
orchestrionVite.mockClear();
const plugins = await getSentrySvelteKitPlugins({ adapter: 'cloudflare' });
const pluginNames = plugins.map(plugin => plugin.name);
expect(orchestrionVite).not.toHaveBeenCalled();
expect(pluginNames).not.toContain('sentry-orchestrion-vite');
expect(pluginNames).not.toContain('sentry-orchestrion-disabled');
expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true }));
expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-vite');
});

it("doesn't inject channel subscribers for non-cloudflare adapters", async () => {
orchestrionVite.mockClear();
await getSentrySvelteKitPlugins({ adapter: 'node' });
expect(orchestrionVite).toHaveBeenCalledWith(
expect.not.objectContaining({ injectChannelSubscribers: expect.anything() }),
);
});

it('passes user-specified vite plugin options to the custom sentry source maps plugin', async () => {
Expand Down
Loading