Skip to content

Commit cb69761

Browse files
authored
feat(deno): Add orchestrion deno runtime hook (#21451)
Use the orchestrion loader hook defined in server-utils, and create a loader for Deno that detects the presence of the hooks, and instruments the channels added to the mysql module. Documentation added to call out the caveat of usage in Deno v2.8.0 through 2.8.2, which is fixed in 2.8.3.
1 parent 1e057ba commit cb69761

22 files changed

Lines changed: 561 additions & 3 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,7 +998,7 @@ jobs:
998998
- name: Set up Deno
999999
if:
10001000
matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application ==
1001-
'deno-redis' || matrix.test-application == 'hono-4'
1001+
'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql'
10021002
uses: denoland/setup-deno@v2.0.4
10031003
with:
10041004
deno-version: ${{ matrix.deno-version || 'v2.8.0' }}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"imports": {
3+
"@sentry/deno": "npm:@sentry/deno",
4+
"mysql": "npm:mysql@2.18.1"
5+
},
6+
"nodeModulesDir": "manual"
7+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
services:
2+
db:
3+
image: mysql:8.0
4+
restart: always
5+
container_name: e2e-tests-deno-mysql
6+
# The `mysql` 2.x driver doesn't speak MySQL 8's default
7+
# `caching_sha2_password` auth, so force the legacy plugin.
8+
command: ['--default-authentication-plugin=mysql_native_password']
9+
ports:
10+
- '3306:3306'
11+
environment:
12+
MYSQL_ROOT_PASSWORD: password
13+
healthcheck:
14+
test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword']
15+
interval: 2s
16+
timeout: 3s
17+
retries: 30
18+
start_period: 10s
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalSetup() {
8+
// Start MySQL via Docker Compose. `--wait` blocks until the healthcheck
9+
// in docker-compose.yml passes, so the Deno app can connect immediately.
10+
execSync('docker compose up -d --wait', {
11+
cwd: __dirname,
12+
stdio: 'inherit',
13+
});
14+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalTeardown() {
8+
execSync('docker compose down --volumes', {
9+
cwd: __dirname,
10+
stdio: 'inherit',
11+
});
12+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "deno-mysql",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts",
7+
"test": "playwright test",
8+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
9+
"test:build": "pnpm install",
10+
"test:assert": "pnpm test"
11+
},
12+
"dependencies": {
13+
"@sentry/deno": "file:../../packed/sentry-deno-packed.tgz",
14+
"mysql": "2.18.1"
15+
},
16+
"devDependencies": {
17+
"@playwright/test": "~1.56.0",
18+
"@sentry-internal/test-utils": "link:../../../test-utils"
19+
},
20+
"volta": {
21+
"extends": "../../package.json"
22+
}
23+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
port: 3030,
6+
});
7+
8+
export default {
9+
...config,
10+
globalSetup: './global-setup.mjs',
11+
globalTeardown: './global-teardown.mjs',
12+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// `@sentry/deno/import` MUST be the very first import: it registers the
2+
// orchestrion runtime hook, which transforms `mysql` (imported dynamically
3+
// below) to publish the `orchestrion:mysql:query` diagnostics channel.
4+
// In Deno 2.8.0–2.8.2 the hook only works as the first import in the entry graph.
5+
import '@sentry/deno/import';
6+
import * as Sentry from '@sentry/deno';
7+
8+
Sentry.init({
9+
environment: 'qa',
10+
dsn: Deno.env.get('E2E_TEST_DSN'),
11+
debug: !!Deno.env.get('DEBUG'),
12+
tunnel: 'http://localhost:3031/', // proxy server
13+
tracesSampleRate: 1,
14+
});
15+
16+
// Dynamic import AFTER init so the orchestrion hook (registered above) is in
17+
// place to transform `mysql/lib/Connection.js`'s `query`, and so
18+
// `denoMysqlIntegration` (wired by `init()`) is already subscribed.
19+
const { default: mysql } = await import('mysql');
20+
21+
const connection = mysql.createConnection({
22+
host: Deno.env.get('MYSQL_HOST') ?? '127.0.0.1',
23+
port: Number(Deno.env.get('MYSQL_PORT') ?? 3306),
24+
user: 'root',
25+
password: 'password',
26+
});
27+
28+
// Swallow connection errors (e.g. the DB container going away at teardown) so
29+
// they don't become an uncaught exception that crashes the process on shutdown.
30+
connection.on('error', (err: unknown) => {
31+
// eslint-disable-next-line no-console
32+
console.error('mysql connection error', err);
33+
});
34+
35+
connection.connect((err: unknown) => {
36+
if (err) {
37+
// eslint-disable-next-line no-console
38+
console.error('mysql connect error', err);
39+
}
40+
});
41+
42+
const port = 3030;
43+
44+
Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => {
45+
const url = new URL(req.url);
46+
47+
// Runs two queries, the second NESTED inside the first's callback. mysql
48+
// dispatches that callback from its socket data handler (a fresh async
49+
// context), so the nested query's span only lands on this request's
50+
// http.server transaction if `denoMysqlIntegration`'s AsyncLocalStorage
51+
// context strategy restored the parent across the async boundary.
52+
if (url.pathname === '/test-mysql') {
53+
await new Promise<void>((resolve, reject) => {
54+
connection.query('SELECT 1 + 1 AS solution', (err: unknown) => {
55+
if (err) return reject(err);
56+
connection.query('SELECT NOW()', (err2: unknown) => {
57+
if (err2) return reject(err2);
58+
resolve();
59+
});
60+
});
61+
});
62+
return Response.json({ status: 'ok' });
63+
}
64+
65+
return new Response('Not found', { status: 404 });
66+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'deno-mysql',
6+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('mysql queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => {
5+
// Each incoming request gets a Sentry http.server transaction (via the
6+
// default denoServeIntegration); the mysql queries run inside it, so their
7+
// db spans attach to that transaction.
8+
const transactionPromise = waitForTransaction('deno-mysql', event => {
9+
return (
10+
event?.contexts?.trace?.op === 'http.server' &&
11+
(event.request?.url ?? '').includes('/test-mysql') &&
12+
(event.spans?.some(span => span.op === 'db') ?? false)
13+
);
14+
});
15+
16+
const res = await fetch(`${baseURL}/test-mysql`);
17+
expect(res.status).toBe(200);
18+
await res.json();
19+
20+
const transaction = await transactionPromise;
21+
const dbSpans = transaction.spans!.filter(span => span.op === 'db');
22+
23+
const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
24+
expect(firstQuery).toBeDefined();
25+
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.orchestrion.mysql');
26+
expect(firstQuery!.data?.['db.system']).toBe('mysql');
27+
expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution');
28+
expect(firstQuery!.data?.['net.peer.port']).toBe(3306);
29+
expect(firstQuery!.data?.['db.user']).toBe('root');
30+
});
31+
32+
test('a nested query lands on the same transaction (AsyncLocalStorage context restored)', async ({ baseURL }) => {
33+
// The second query runs inside the first query's callback — i.e. across
34+
// mysql's async socket-callback dispatch. Both spans appearing on the SAME
35+
// http.server transaction proves denoMysqlIntegration's context strategy
36+
// restored the parent span across that async boundary (otherwise the nested
37+
// query would start its own trace and never join this transaction).
38+
const transactionPromise = waitForTransaction('deno-mysql', event => {
39+
return (
40+
event?.contexts?.trace?.op === 'http.server' &&
41+
(event.request?.url ?? '').includes('/test-mysql') &&
42+
(event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
43+
);
44+
});
45+
46+
const res = await fetch(`${baseURL}/test-mysql`);
47+
expect(res.status).toBe(200);
48+
await res.json();
49+
50+
const transaction = await transactionPromise;
51+
const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description);
52+
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
53+
expect(descriptions).toContain('SELECT NOW()');
54+
});

0 commit comments

Comments
 (0)