Skip to content

Commit ce6f731

Browse files
authored
test(node): Update mysql tests for better coverage and correctness (#21684)
This updates the node-integration-tests for `mysql` package for better test coverage (in preparation of #21568), as well as also fixing some holes in the tests: * Previously, we did not have any MySQL server, which lead to some weird quirks, e.g. spans being marked as failed because no server is found etc. This kind of skewed the results. This PR introduces a minimal, node-written MySQL server that only replies with OK/ERROR, just enough for our tests. * Add tests for connection pooling * Add tests for streamed query errors * Add tests for context stability in streamed queries
1 parent ff4c9ba commit ce6f731

9 files changed

Lines changed: 357 additions & 63 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/* eslint-disable no-bitwise */
2+
3+
// A tiny, dependency-free MySQL server that speaks just enough of the v10 wire
4+
// protocol for the `mysql` client to connect and run queries. It completes the
5+
// handshake (so `pool.getConnection()` resolves and reaches `connection.query`)
6+
// and replies to every command with a success OK packet (the client treats it
7+
// as a 0-row result, even for `SELECT`). This gives pool queries a real,
8+
// successful connection to instrument — no docker / real database required.
9+
import net from 'node:net';
10+
11+
// MySQL capability flags (only the ones the client checks here).
12+
const CLIENT_PROTOCOL_41 = 0x00000200;
13+
const CLIENT_SECURE_CONNECTION = 0x00008000;
14+
const CLIENT_PLUGIN_AUTH = 0x00080000;
15+
const SERVER_CAPABILITIES = CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION | CLIENT_PLUGIN_AUTH;
16+
17+
/** Wrap a payload in a MySQL packet: 3-byte little-endian length + 1-byte sequence id. */
18+
function packet(seq: number, payload: Buffer): Buffer {
19+
const header = Buffer.alloc(4);
20+
header.writeUIntLE(payload.length, 0, 3);
21+
header.writeUInt8(seq, 3);
22+
return Buffer.concat([header, payload]);
23+
}
24+
25+
function initialHandshake() {
26+
const scramble = Buffer.alloc(20, 1); // 20-byte auth-plugin-data (value is irrelevant — we never verify)
27+
const parts = [
28+
Buffer.from([0x0a]), // protocol version 10
29+
Buffer.from('8.0.0-sentry-test\0', 'latin1'), // server version (NUL-terminated)
30+
Buffer.from([1, 0, 0, 0]), // connection id
31+
scramble.subarray(0, 8), // auth-plugin-data-part-1
32+
Buffer.from([0x00]), // filler
33+
Buffer.from([SERVER_CAPABILITIES & 0xff, (SERVER_CAPABILITIES >> 8) & 0xff]), // capability flags (lower)
34+
Buffer.from([0x21]), // charset (utf8_general_ci)
35+
Buffer.from([0x02, 0x00]), // status flags
36+
Buffer.from([(SERVER_CAPABILITIES >> 16) & 0xff, (SERVER_CAPABILITIES >> 24) & 0xff]), // capability flags (upper)
37+
Buffer.from([21]), // length of auth-plugin-data
38+
Buffer.alloc(10, 0), // reserved
39+
Buffer.concat([scramble.subarray(8), Buffer.from([0x00])]), // auth-plugin-data-part-2 (+ NUL)
40+
Buffer.from('mysql_native_password\0', 'latin1'),
41+
];
42+
return Buffer.concat(parts);
43+
}
44+
45+
function okPacket(): Buffer {
46+
// OK header, 0 affected rows, 0 insert id, status flags, 0 warnings. The client accepts this for any
47+
// command — including a `SELECT` (treated as a successful 0-row result) — so spans get `status: ok`.
48+
return Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]);
49+
}
50+
51+
function errPacket(): Buffer {
52+
// ERR for queries that should fail: code 1146 (ER_NO_SUCH_TABLE), SQL state "42S02". The client
53+
// surfaces this as a query error, so the span gets `status: internal_error`.
54+
const head = Buffer.from([0xff, 0x7a, 0x04]); // 0xff + error code 1146 (LE)
55+
const state = Buffer.from('#42S02', 'latin1');
56+
const msg = Buffer.from("Table 'does_not_exist' doesn't exist", 'latin1');
57+
return Buffer.concat([head, state, msg]);
58+
}
59+
60+
/**
61+
* Start the server on the given host/port. Returns the `net.Server` (call `.close()` to stop).
62+
*
63+
* `host` defaults to `undefined` so the server listens on all interfaces (dual-stack). The `mysql`
64+
* client connects to `localhost`, which resolves to IPv6 `::1` first — binding only to IPv4
65+
* `127.0.0.1` would get `ECONNREFUSED ::1` on Node 18 (where `autoSelectFamily` is off, so it never
66+
* falls back to IPv4 the way Node 20+ does).
67+
*/
68+
export function startMysqlTestServer({ host, port = 0 }: { host?: string; port?: number } = {}) {
69+
const server = net.createServer(socket => {
70+
socket.on('error', () => {}); // ignore abrupt client disconnects
71+
socket.write(packet(0, initialHandshake()));
72+
73+
let sawHandshakeResponse = false;
74+
let buffered = Buffer.alloc(0);
75+
socket.on('data', (chunk: Buffer) => {
76+
// TCP may coalesce several packets into one `data` event or split one packet across events, so
77+
// we can't assume one packet per read. Accumulate bytes and frame on the 3-byte length header,
78+
// consuming only whole packets — otherwise a coalesced handshake-response + COM_QUERY would lose
79+
// its tail and the client would hang.
80+
buffered = buffered.length ? Buffer.concat([buffered, chunk]) : chunk;
81+
82+
while (buffered.length >= 4) {
83+
// Packet: [3-byte LE payload length][1-byte seq][payload].
84+
const payloadLength = buffered.readUIntLE(0, 3);
85+
const packetLength = 4 + payloadLength;
86+
if (buffered.length < packetLength) {
87+
break; // rest of this packet hasn't arrived yet
88+
}
89+
const pkt = buffered.subarray(0, packetLength);
90+
buffered = buffered.subarray(packetLength);
91+
92+
if (!sawHandshakeResponse) {
93+
// First inbound packet is the client's handshake response → accept auth.
94+
sawHandshakeResponse = true;
95+
socket.write(packet(2, okPacket()));
96+
continue;
97+
}
98+
99+
// Command packet: payload is [1-byte command][args]. For COM_QUERY (0x03) the args are the SQL
100+
// text. Queries referencing the conventional missing table fail (so error-path tests work);
101+
// every other command succeeds with an OK. The command resets the sequence, so our reply is seq 1.
102+
const isQuery = payloadLength > 1 && pkt[4] === 0x03;
103+
const sql = isQuery ? pkt.subarray(5).toString('latin1') : '';
104+
socket.write(packet(1, sql.includes('does_not_exist') ? errPacket() : okPacket()));
105+
}
106+
});
107+
});
108+
// Never let a listen error crash the test process.
109+
server.on('error', () => {});
110+
server.listen(port, host);
111+
return server;
112+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as Sentry from '@sentry/node';
2+
import mysql from 'mysql';
3+
4+
const connection = mysql.createConnection({
5+
port: Number(process.env.MYSQL_PORT),
6+
user: 'root',
7+
password: 'docker',
8+
});
9+
10+
connection.connect(function (err) {
11+
if (err) {
12+
return;
13+
}
14+
});
15+
16+
Sentry.startSpanManual(
17+
{
18+
op: 'transaction',
19+
name: 'Test Transaction',
20+
},
21+
span => {
22+
const query = connection.query('SELECT 1 + 1 AS solution');
23+
24+
// This should _not_ be the parent of the listener-child!
25+
Sentry.startSpanManual({ name: 'inner-span' }, innerSpan => {
26+
// The instrumentation registers its own `end` listener (which finishes the query span) when
27+
// `query()` is called, before this one — so by the time we run here, the query span is finished.
28+
query.on('end', () => {
29+
// A span started from inside a stream listener should be a child of the parent context that was
30+
// active when the query was issued (the transaction here), not of the query span itself. This
31+
// verifies the instrumentation re-binds the streamed query's events to the parent context.
32+
Sentry.startSpan({ name: 'listener-child' }, () => {
33+
// noop
34+
});
35+
36+
innerSpan.end();
37+
span.end();
38+
connection.end();
39+
});
40+
});
41+
},
42+
);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as Sentry from '@sentry/node';
2+
import mysql from 'mysql';
3+
4+
const connection = mysql.createConnection({
5+
port: Number(process.env.MYSQL_PORT),
6+
user: 'root',
7+
password: 'docker',
8+
});
9+
10+
connection.connect(function (err) {
11+
if (err) {
12+
return;
13+
}
14+
});
15+
16+
Sentry.startSpanManual(
17+
{
18+
op: 'transaction',
19+
name: 'Test Transaction',
20+
},
21+
span => {
22+
// Query without a callback returns a streamable `Query`. A failing query emits an `error` event
23+
// (which sets the span status) followed by `end` (which ends the span).
24+
const query = connection.query('SELECT * FROM does_not_exist');
25+
26+
// Swallow the error so it doesn't crash the process
27+
query.on('error', () => {
28+
// noop
29+
});
30+
31+
query.on('end', () => {
32+
span.end();
33+
connection.end();
34+
});
35+
},
36+
);

dev-packages/node-integration-tests/suites/tracing/mysql/scenario-withConnect.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
22
import mysql from 'mysql';
33

44
const connection = mysql.createConnection({
5+
port: Number(process.env.MYSQL_PORT),
56
user: 'root',
67
password: 'docker',
78
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/node';
2+
import mysql from 'mysql';
3+
4+
const pool = mysql.createPool({
5+
port: Number(process.env.MYSQL_PORT),
6+
user: 'root',
7+
password: 'docker',
8+
});
9+
10+
Sentry.startSpanManual(
11+
{
12+
op: 'transaction',
13+
name: 'Test Transaction',
14+
},
15+
span => {
16+
pool.query('SELECT 1 + 1 AS solution', function () {
17+
pool.query('SELECT NOW()', ['1', '2'], () => {
18+
span.end();
19+
pool.end();
20+
});
21+
});
22+
},
23+
);

dev-packages/node-integration-tests/suites/tracing/mysql/scenario-withoutCallback.mjs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
22
import mysql from 'mysql';
33

44
const connection = mysql.createConnection({
5+
port: Number(process.env.MYSQL_PORT),
56
user: 'root',
67
password: 'docker',
78
});
@@ -23,10 +24,7 @@ Sentry.startSpanManual(
2324

2425
query.on('end', () => {
2526
query2.on('end', () => {
26-
// Wait a bit to ensure the queries completed
27-
setTimeout(() => {
28-
span.end();
29-
}, 500);
27+
span.end();
3028
});
3129
});
3230
},

dev-packages/node-integration-tests/suites/tracing/mysql/scenario-withoutConnect.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
22
import mysql from 'mysql';
33

44
const connection = mysql.createConnection({
5+
port: Number(process.env.MYSQL_PORT),
56
user: 'root',
67
password: 'docker',
78
});

0 commit comments

Comments
 (0)