Skip to content

Commit 35b6b26

Browse files
authored
test(node): Add mysql2 auto instrumentation test for @sentry/node-experimental (#10259)
This PR adds a test for `mysql2` auto instrumentation for `@sentry/node-experimental`. `mysql2` will not query unless there is a connection and it does not want to work without one like `mysql`. This PR adds a `withDockerCompose` method to the test runner which handles: - Starting the docker container - Waiting until some specific output has been seen from the container so we know the server is up - Closing and cleaning up the docker container/volumes ```ts createRunner(__dirname, 'scenario.js') .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port: 3306'] }) .expect({ transaction: EXPECTED_TRANSACTION }) .start(done); ``` My only minor concern is that the mysql docker container creates a volume to store data. If the cleanup code does not run, a 180MB volume is left behind after every run. This will only be an issue when testing locally but we could start to fill developers machines. These can be cleaned up via `docker volume prune --force` but I would not want to run this on peoples machines without telling them!
1 parent d64b798 commit 35b6b26

File tree

8 files changed

+297
-67
lines changed

8 files changed

+297
-67
lines changed

dev-packages/node-integration-tests/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"build:dev": "yarn build",
1515
"build:transpile": "rollup -c rollup.npm.config.mjs",
1616
"build:types": "tsc -p tsconfig.types.json",
17-
"clean": "rimraf -g **/node_modules",
17+
"clean": "rimraf -g **/node_modules && run-p clean:docker:*",
18+
"clean:docker:mysql2": "cd suites/tracing-experimental/mysql2 && docker-compose down --volumes",
1819
"prisma:init": "(cd suites/tracing/prisma-orm && ts-node ./setup.ts)",
1920
"prisma:init:new": "(cd suites/tracing-new/prisma-orm && ts-node ./setup.ts)",
2021
"lint": "eslint . --format stylish",
@@ -44,6 +45,7 @@
4445
"mongodb-memory-server-global": "^7.6.3",
4546
"mongoose": "^5.13.22",
4647
"mysql": "^2.18.1",
48+
"mysql2": "^3.7.1",
4749
"nock": "^13.1.0",
4850
"pg": "^8.7.3",
4951
"proxy": "^2.1.1",

dev-packages/node-integration-tests/suites/proxy/test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { createRunner } from '../../utils/runner';
1+
import { cleanupChildProcesses, createRunner } from '../../utils/runner';
2+
3+
afterAll(() => {
4+
cleanupChildProcesses();
5+
});
26

37
test('proxies sentry requests', done => {
48
createRunner(__dirname, 'basic.js')

dev-packages/node-integration-tests/suites/tracing-experimental/mysql/test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { conditionalTest } from '../../../utils';
2-
import { createRunner } from '../../../utils/runner';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
33

44
conditionalTest({ min: 14 })('mysql auto instrumentation', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
59
test('should auto-instrument `mysql` package when using connection.connect()', done => {
610
const EXPECTED_TRANSACTION = {
711
transaction: 'Test Transaction',
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
services:
2+
db:
3+
image: mysql:8
4+
restart: always
5+
container_name: integration-tests-mysql
6+
ports:
7+
- '3306:3306'
8+
environment:
9+
MYSQL_ROOT_PASSWORD: password
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const { loggingTransport } = require('@sentry-internal/node-integration-tests');
2+
const Sentry = require('@sentry/node-experimental');
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
10+
11+
// Stop the process from exiting before the transaction is sent
12+
setInterval(() => {}, 1000);
13+
14+
const mysql = require('mysql2/promise');
15+
16+
mysql
17+
.createConnection({
18+
user: 'root',
19+
password: 'password',
20+
host: 'localhost',
21+
port: 3306,
22+
})
23+
.then(connection => {
24+
return Sentry.startSpan(
25+
{
26+
op: 'transaction',
27+
name: 'Test Transaction',
28+
},
29+
async _ => {
30+
await connection.query('SELECT 1 + 1 AS solution');
31+
await connection.query('SELECT NOW()', ['1', '2']);
32+
},
33+
);
34+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { conditionalTest } from '../../../utils';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
conditionalTest({ min: 14 })('mysql2 auto instrumentation', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
9+
test('should auto-instrument `mysql` package without connection.connect()', done => {
10+
const EXPECTED_TRANSACTION = {
11+
transaction: 'Test Transaction',
12+
spans: expect.arrayContaining([
13+
expect.objectContaining({
14+
description: 'SELECT 1 + 1 AS solution',
15+
op: 'db',
16+
data: expect.objectContaining({
17+
'db.system': 'mysql',
18+
'net.peer.name': 'localhost',
19+
'net.peer.port': 3306,
20+
'db.user': 'root',
21+
}),
22+
}),
23+
expect.objectContaining({
24+
description: 'SELECT NOW()',
25+
op: 'db',
26+
data: expect.objectContaining({
27+
'db.system': 'mysql',
28+
'net.peer.name': 'localhost',
29+
'net.peer.port': 3306,
30+
'db.user': 'root',
31+
}),
32+
}),
33+
]),
34+
};
35+
36+
createRunner(__dirname, 'scenario.js')
37+
.withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port: 3306'] })
38+
.expect({ transaction: EXPECTED_TRANSACTION })
39+
.start(done);
40+
});
41+
});

dev-packages/node-integration-tests/utils/runner.ts

Lines changed: 137 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { ChildProcess } from 'child_process';
2-
import { spawn } from 'child_process';
1+
import { spawn, spawnSync } from 'child_process';
32
import { join } from 'path';
43
import type { Envelope, EnvelopeItemType, Event, SerializedSession } from '@sentry/types';
54
import axios from 'axios';
@@ -30,14 +29,17 @@ export function assertSentryTransaction(actual: Event, expected: Partial<Event>)
3029
});
3130
}
3231

33-
const CHILD_PROCESSES = new Set<ChildProcess>();
32+
const CLEANUP_STEPS = new Set<VoidFunction>();
3433

3534
export function cleanupChildProcesses(): void {
36-
for (const child of CHILD_PROCESSES) {
37-
child.kill();
35+
for (const step of CLEANUP_STEPS) {
36+
step();
3837
}
38+
CLEANUP_STEPS.clear();
3939
}
4040

41+
process.on('exit', cleanupChildProcesses);
42+
4143
/** Promise only resolves when fn returns true */
4244
async function waitFor(fn: () => boolean, timeout = 10_000): Promise<void> {
4345
let remaining = timeout;
@@ -50,6 +52,58 @@ async function waitFor(fn: () => boolean, timeout = 10_000): Promise<void> {
5052
}
5153
}
5254

55+
type VoidFunction = () => void;
56+
57+
interface DockerOptions {
58+
/**
59+
* The working directory to run docker compose in
60+
*/
61+
workingDirectory: string[];
62+
/**
63+
* The strings to look for in the output to know that the docker compose is ready for the test to be run
64+
*/
65+
readyMatches: string[];
66+
}
67+
68+
/**
69+
* Runs docker compose up and waits for the readyMatches to appear in the output
70+
*
71+
* Returns a function that can be called to docker compose down
72+
*/
73+
async function runDockerCompose(options: DockerOptions): Promise<VoidFunction> {
74+
return new Promise((resolve, reject) => {
75+
const cwd = join(...options.workingDirectory);
76+
const close = (): void => {
77+
spawnSync('docker', ['compose', 'down', '--volumes'], { cwd });
78+
};
79+
80+
// ensure we're starting fresh
81+
close();
82+
83+
const child = spawn('docker', ['compose', 'up'], { cwd });
84+
85+
const timeout = setTimeout(() => {
86+
close();
87+
reject(new Error('Timed out waiting for docker-compose'));
88+
}, 60_000);
89+
90+
function newData(data: Buffer): void {
91+
const text = data.toString('utf8');
92+
93+
for (const match of options.readyMatches) {
94+
if (text.includes(match)) {
95+
child.stdout.removeAllListeners();
96+
clearTimeout(timeout);
97+
resolve(close);
98+
}
99+
}
100+
}
101+
102+
child.stdout.on('data', newData);
103+
child.stderr.on('data', newData);
104+
});
105+
}
106+
53107
type Expected =
54108
| {
55109
event: Partial<Event> | ((event: Event) => void);
@@ -70,6 +124,7 @@ export function createRunner(...paths: string[]) {
70124
const flags: string[] = [];
71125
const ignored: EnvelopeItemType[] = [];
72126
let withSentryServer = false;
127+
let dockerOptions: DockerOptions | undefined;
73128
let ensureNoErrorOutput = false;
74129

75130
if (testPath.endsWith('.ts')) {
@@ -93,6 +148,10 @@ export function createRunner(...paths: string[]) {
93148
ignored.push(...types);
94149
return this;
95150
},
151+
withDockerCompose: function (options: DockerOptions) {
152+
dockerOptions = options;
153+
return this;
154+
},
96155
ensureNoErrorOutput: function () {
97156
ensureNoErrorOutput = true;
98157
return this;
@@ -182,80 +241,94 @@ export function createRunner(...paths: string[]) {
182241
? createBasicSentryServer(newEnvelope)
183242
: Promise.resolve(undefined);
184243

244+
const dockerStartup: Promise<VoidFunction | undefined> = dockerOptions
245+
? runDockerCompose(dockerOptions)
246+
: Promise.resolve(undefined);
247+
248+
const startup = Promise.all([dockerStartup, serverStartup]);
249+
185250
// eslint-disable-next-line @typescript-eslint/no-floating-promises
186-
serverStartup.then(mockServerPort => {
187-
const env = mockServerPort
188-
? { ...process.env, SENTRY_DSN: `http://public@localhost:${mockServerPort}/1337` }
189-
: process.env;
251+
startup
252+
.then(([dockerChild, mockServerPort]) => {
253+
if (dockerChild) {
254+
CLEANUP_STEPS.add(dockerChild);
255+
}
190256

191-
// eslint-disable-next-line no-console
192-
if (process.env.DEBUG) console.log('starting scenario', testPath, flags, env.SENTRY_DSN);
257+
const env = mockServerPort
258+
? { ...process.env, SENTRY_DSN: `http://public@localhost:${mockServerPort}/1337` }
259+
: process.env;
193260

194-
child = spawn('node', [...flags, testPath], { env });
261+
// eslint-disable-next-line no-console
262+
if (process.env.DEBUG) console.log('starting scenario', testPath, flags, env.SENTRY_DSN);
195263

196-
CHILD_PROCESSES.add(child);
264+
child = spawn('node', [...flags, testPath], { env });
197265

198-
if (ensureNoErrorOutput) {
199-
child.stderr.on('data', (data: Buffer) => {
200-
const output = data.toString();
201-
complete(new Error(`Expected no error output but got: '${output}'`));
266+
CLEANUP_STEPS.add(() => {
267+
child?.kill();
202268
});
203-
}
204-
205-
child.on('close', () => {
206-
hasExited = true;
207269

208270
if (ensureNoErrorOutput) {
209-
complete();
271+
child.stderr.on('data', (data: Buffer) => {
272+
const output = data.toString();
273+
complete(new Error(`Expected no error output but got: '${output}'`));
274+
});
210275
}
211-
});
212276

213-
// Pass error to done to end the test quickly
214-
child.on('error', e => {
215-
// eslint-disable-next-line no-console
216-
if (process.env.DEBUG) console.log('scenario error', e);
217-
complete(e);
218-
});
219-
220-
function tryParseEnvelopeFromStdoutLine(line: string): void {
221-
// Lines can have leading '[something] [{' which we need to remove
222-
const cleanedLine = line.replace(/^.*?] \[{"/, '[{"');
223-
224-
// See if we have a port message
225-
if (cleanedLine.startsWith('{"port":')) {
226-
const { port } = JSON.parse(cleanedLine) as { port: number };
227-
scenarioServerPort = port;
228-
return;
229-
}
277+
child.on('close', () => {
278+
hasExited = true;
230279

231-
// Skip any lines that don't start with envelope JSON
232-
if (!cleanedLine.startsWith('[{')) {
233-
return;
234-
}
280+
if (ensureNoErrorOutput) {
281+
complete();
282+
}
283+
});
235284

236-
try {
237-
const envelope = JSON.parse(cleanedLine) as Envelope;
238-
newEnvelope(envelope);
239-
} catch (_) {
240-
//
241-
}
242-
}
285+
// Pass error to done to end the test quickly
286+
child.on('error', e => {
287+
// eslint-disable-next-line no-console
288+
if (process.env.DEBUG) console.log('scenario error', e);
289+
complete(e);
290+
});
243291

244-
let buffer = Buffer.alloc(0);
245-
child.stdout.on('data', (data: Buffer) => {
246-
// This is horribly memory inefficient but it's only for tests
247-
buffer = Buffer.concat([buffer, data]);
292+
function tryParseEnvelopeFromStdoutLine(line: string): void {
293+
// Lines can have leading '[something] [{' which we need to remove
294+
const cleanedLine = line.replace(/^.*?] \[{"/, '[{"');
248295

249-
let splitIndex = -1;
250-
while ((splitIndex = buffer.indexOf(0xa)) >= 0) {
251-
const line = buffer.subarray(0, splitIndex).toString();
252-
buffer = Buffer.from(buffer.subarray(splitIndex + 1));
253-
// eslint-disable-next-line no-console
254-
if (process.env.DEBUG) console.log('line', line);
255-
tryParseEnvelopeFromStdoutLine(line);
296+
// See if we have a port message
297+
if (cleanedLine.startsWith('{"port":')) {
298+
const { port } = JSON.parse(cleanedLine) as { port: number };
299+
scenarioServerPort = port;
300+
return;
301+
}
302+
303+
// Skip any lines that don't start with envelope JSON
304+
if (!cleanedLine.startsWith('[{')) {
305+
return;
306+
}
307+
308+
try {
309+
const envelope = JSON.parse(cleanedLine) as Envelope;
310+
newEnvelope(envelope);
311+
} catch (_) {
312+
//
313+
}
256314
}
257-
});
258-
});
315+
316+
let buffer = Buffer.alloc(0);
317+
child.stdout.on('data', (data: Buffer) => {
318+
// This is horribly memory inefficient but it's only for tests
319+
buffer = Buffer.concat([buffer, data]);
320+
321+
let splitIndex = -1;
322+
while ((splitIndex = buffer.indexOf(0xa)) >= 0) {
323+
const line = buffer.subarray(0, splitIndex).toString();
324+
buffer = Buffer.from(buffer.subarray(splitIndex + 1));
325+
// eslint-disable-next-line no-console
326+
if (process.env.DEBUG) console.log('line', line);
327+
tryParseEnvelopeFromStdoutLine(line);
328+
}
329+
});
330+
})
331+
.catch(e => complete(e));
259332

260333
return {
261334
childHasExited: function (): boolean {

0 commit comments

Comments
 (0)