Skip to content
Open
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,105 @@
/* eslint-disable no-bitwise */

// A tiny, dependency-free MySQL server that speaks just enough of the v10 wire
// protocol for the `mysql` client to connect and run queries. It completes the
// handshake (so `pool.getConnection()` resolves and reaches `connection.query`)
// and replies to every command with a success OK packet (the client treats it
// as a 0-row result, even for `SELECT`). This gives pool queries a real,
// successful connection to instrument — no docker / real database required.
import net from 'node:net';

// MySQL capability flags (only the ones the client checks here).
const CLIENT_PROTOCOL_41 = 0x00000200;
const CLIENT_SECURE_CONNECTION = 0x00008000;
const CLIENT_PLUGIN_AUTH = 0x00080000;
const SERVER_CAPABILITIES = CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION | CLIENT_PLUGIN_AUTH;

/** Wrap a payload in a MySQL packet: 3-byte little-endian length + 1-byte sequence id. */
function packet(seq: number, payload: Buffer): Buffer {
const header = Buffer.alloc(4);
header.writeUIntLE(payload.length, 0, 3);
header.writeUInt8(seq, 3);
return Buffer.concat([header, payload]);
}

function initialHandshake() {
const scramble = Buffer.alloc(20, 1); // 20-byte auth-plugin-data (value is irrelevant — we never verify)
const parts = [
Buffer.from([0x0a]), // protocol version 10
Buffer.from('8.0.0-sentry-test\0', 'latin1'), // server version (NUL-terminated)
Buffer.from([1, 0, 0, 0]), // connection id
scramble.subarray(0, 8), // auth-plugin-data-part-1
Buffer.from([0x00]), // filler
Buffer.from([SERVER_CAPABILITIES & 0xff, (SERVER_CAPABILITIES >> 8) & 0xff]), // capability flags (lower)
Buffer.from([0x21]), // charset (utf8_general_ci)
Buffer.from([0x02, 0x00]), // status flags
Buffer.from([(SERVER_CAPABILITIES >> 16) & 0xff, (SERVER_CAPABILITIES >> 24) & 0xff]), // capability flags (upper)
Buffer.from([21]), // length of auth-plugin-data
Buffer.alloc(10, 0), // reserved
Buffer.concat([scramble.subarray(8), Buffer.from([0x00])]), // auth-plugin-data-part-2 (+ NUL)
Buffer.from('mysql_native_password\0', 'latin1'),
];
return Buffer.concat(parts);
}

function okPacket(): Buffer {
// OK header, 0 affected rows, 0 insert id, status flags, 0 warnings. The client accepts this for any
// command — including a `SELECT` (treated as a successful 0-row result) — so spans get `status: ok`.
return Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]);
}

function errPacket(): Buffer {
// ERR for queries that should fail: code 1146 (ER_NO_SUCH_TABLE), SQL state "42S02". The client
// surfaces this as a query error, so the span gets `status: internal_error`.
const head = Buffer.from([0xff, 0x7a, 0x04]); // 0xff + error code 1146 (LE)
const state = Buffer.from('#42S02', 'latin1');
const msg = Buffer.from("Table 'does_not_exist' doesn't exist", 'latin1');
return Buffer.concat([head, state, msg]);
}

/** Start the server on the given host/port. Returns the `net.Server` (call `.close()` to stop). */
export function startMysqlTestServer({ host = '127.0.0.1', port = 0 } = {}) {
const server = net.createServer(socket => {
socket.on('error', () => {}); // ignore abrupt client disconnects
socket.write(packet(0, initialHandshake()));

let sawHandshakeResponse = false;
let buffered = Buffer.alloc(0);
socket.on('data', (chunk: Buffer) => {
// TCP may coalesce several packets into one `data` event or split one packet across events, so
// we can't assume one packet per read. Accumulate bytes and frame on the 3-byte length header,
// consuming only whole packets — otherwise a coalesced handshake-response + COM_QUERY would lose
// its tail and the client would hang.
buffered = buffered.length ? Buffer.concat([buffered, chunk]) : chunk;

while (buffered.length >= 4) {
// Packet: [3-byte LE payload length][1-byte seq][payload].
const payloadLength = buffered.readUIntLE(0, 3);
const packetLength = 4 + payloadLength;
if (buffered.length < packetLength) {
break; // rest of this packet hasn't arrived yet
}
const pkt = buffered.subarray(0, packetLength);
buffered = buffered.subarray(packetLength);

if (!sawHandshakeResponse) {
// First inbound packet is the client's handshake response → accept auth.
sawHandshakeResponse = true;
socket.write(packet(2, okPacket()));
continue;
}

// Command packet: payload is [1-byte command][args]. For COM_QUERY (0x03) the args are the SQL
// text. Queries referencing the conventional missing table fail (so error-path tests work);
// every other command succeeds with an OK. The command resets the sequence, so our reply is seq 1.
const isQuery = payloadLength > 1 && pkt[4] === 0x03;
const sql = isQuery ? pkt.subarray(5).toString('latin1') : '';
socket.write(packet(1, sql.includes('does_not_exist') ? errPacket() : okPacket()));
}
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
// Never let a listen error crash the test process.
server.on('error', () => {});
server.listen(port, host);
return server;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as Sentry from '@sentry/node';
import mysql from 'mysql';

const connection = mysql.createConnection({
port: Number(process.env.MYSQL_PORT),
user: 'root',
password: 'docker',
});

connection.connect(function (err) {
if (err) {
return;
}
});

Sentry.startSpanManual(
{
op: 'transaction',
name: 'Test Transaction',
},
span => {
const query = connection.query('SELECT 1 + 1 AS solution');

// This should _not_ be the parent of the listener-child!
Sentry.startSpanManual({ name: 'inner-span' }, innerSpan => {
// The instrumentation registers its own `end` listener (which finishes the query span) when
// `query()` is called, before this one — so by the time we run here, the query span is finished.
query.on('end', () => {
// A span started from inside a stream listener should be a child of the parent context that was
// active when the query was issued (the transaction here), not of the query span itself. This
// verifies the instrumentation re-binds the streamed query's events to the parent context.
Sentry.startSpan({ name: 'listener-child' }, () => {
// noop
});

innerSpan.end();
span.end();
connection.end();
});
});
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as Sentry from '@sentry/node';
import mysql from 'mysql';

const connection = mysql.createConnection({
port: Number(process.env.MYSQL_PORT),
user: 'root',
password: 'docker',
});

connection.connect(function (err) {
if (err) {
return;
}
});

Sentry.startSpanManual(
{
op: 'transaction',
name: 'Test Transaction',
},
span => {
// Query without a callback returns a streamable `Query`. A failing query emits an `error` event
// (which sets the span status) followed by `end` (which ends the span).
const query = connection.query('SELECT * FROM does_not_exist');

// Swallow the error so it doesn't crash the process
query.on('error', () => {
// noop
});

query.on('end', () => {
span.end();
connection.end();
});
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
import mysql from 'mysql';

const connection = mysql.createConnection({
port: Number(process.env.MYSQL_PORT),
user: 'root',
password: 'docker',
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as Sentry from '@sentry/node';
import mysql from 'mysql';

const pool = mysql.createPool({
port: Number(process.env.MYSQL_PORT),
user: 'root',
password: 'docker',
});

Sentry.startSpanManual(
{
op: 'transaction',
name: 'Test Transaction',
},
span => {
pool.query('SELECT 1 + 1 AS solution', function () {
pool.query('SELECT NOW()', ['1', '2'], () => {
span.end();
pool.end();
});
});
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
import mysql from 'mysql';

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

query.on('end', () => {
query2.on('end', () => {
// Wait a bit to ensure the queries completed
setTimeout(() => {
span.end();
}, 500);
span.end();
});
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/node';
import mysql from 'mysql';

const connection = mysql.createConnection({
port: Number(process.env.MYSQL_PORT),
user: 'root',
password: 'docker',
});
Expand Down
99 changes: 89 additions & 10 deletions dev-packages/node-integration-tests/suites/tracing/mysql/test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { afterAll, describe, expect } from 'vitest';
import type { AddressInfo, Server } from 'node:net';
import { afterAll, beforeAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
import { startMysqlTestServer } from './mysql-test-server';

describe('mysql auto instrumentation', () => {
// A minimal in-process MySQL server (on a random free port) so the client's
// connection handshake succeeds. Without it, `createPool()` queries fail at
// connection acquisition — before `connection.query` runs — so the
// diagnostics-channel instrumentation (which hooks `connection.query`) never
// sees them. Queries still error (the server rejects them), so spans keep
// `status: internal_error` as the assertions expect. The port is passed to
// each scenario via the `MYSQL_PORT` env var.
let mysqlServer: Server;
let mysqlPort: number;
beforeAll(async () => {
mysqlServer = startMysqlTestServer();
await new Promise<void>(resolve => mysqlServer.once('listening', () => resolve()));
mysqlPort = (mysqlServer.address() as AddressInfo).port;
});

afterAll(() => {
mysqlServer?.close();
cleanupChildProcesses();
});

// Builds the expected transaction. When `origin` is given, the spans must also
// carry that `sentry.origin`, which is how we assert that the
// diagnostics-channel instrumentation (not the OTel one) produced them.
function expectedTransaction(origin?: string): Record<string, unknown> {
// diagnostics-channel instrumentation (not the OTel one) produced them. A
// scenario can pass `override` to replace the default transaction expectation
// (e.g. the streamed-error scenario, which runs a different, failing query).
function expectedTransaction(
port: number,
origin: string | undefined,
override: Record<string, unknown> | undefined,
): Record<string, unknown> {
const span = (description: string): ReturnType<typeof expect.objectContaining> =>
expect.objectContaining({
description,
Expand All @@ -18,16 +42,16 @@ describe('mysql auto instrumentation', () => {
data: expect.objectContaining({
'db.system': 'mysql',
'net.peer.name': 'localhost',
'net.peer.port': 3306,
'net.peer.port': port,
'db.user': 'root',
}),
// all db spans have an error status because we don't have an actual mysql DB server running for these tests
status: 'internal_error',
status: 'ok',
});

return {
transaction: 'Test Transaction',
spans: expect.arrayContaining([span('SELECT 1 + 1 AS solution'), span('SELECT NOW()')]),
...(override ?? {}),
};
}

Expand Down Expand Up @@ -71,29 +95,84 @@ describe('mysql auto instrumentation', () => {
['scenario-withConnect.mjs', 'using connection.connect()'],
['scenario-withoutCallback.mjs', 'using query without callback'],
['scenario-withoutConnect.mjs', 'without connection.connect()'],
['scenario-withPool.mjs', 'using createPool()'],
[
'scenario-streamError.mjs',
'streamed query error',
{
// The transaction itself succeeds (status `ok`); only the failing query's child span is errored.
spans: expect.arrayContaining([
expect.objectContaining({
description: 'SELECT * FROM does_not_exist',
op: 'db',
// A failing streamed query emits `error`, which marks the span as errored
status: 'internal_error',
data: expect.objectContaining({
'db.system': 'mysql',
'db.user': 'root',
}),
}),
]),
},
],
Comment thread
cursor[bot] marked this conversation as resolved.
] as const;

for (const { label, instrument, flags, origin, failsOnEsm } of CASES) {
describe(label, () => {
const expected = expectedTransaction(origin);

for (const [scenario, description] of SCENARIOS) {
for (const [scenario, description, transactionOverride] of SCENARIOS) {
createEsmAndCjsTests(
__dirname,
scenario,
instrument,
(createRunner, test) => {
test(`should auto-instrument \`mysql\` package when ${description}`, async () => {
await createRunner()
.withEnv({ MYSQL_PORT: String(mysqlPort) })
.withFlags(...flags)
.expect({ transaction: expected })
.expect({ transaction: expectedTransaction(mysqlPort, origin, transactionOverride) })
.start()
.completed();
});
},
{ failsOnEsm },
);
}

createEsmAndCjsTests(
__dirname,
'scenario-streamContext.mjs',
instrument,
(createTestRunner, test) => {
test('should run streamed query listeners with the parent context active', async () => {
await createTestRunner()
.withFlags(...flags)
.withEnv({ MYSQL_PORT: String(mysqlPort) })
.expect({
transaction: (transaction): void => {
const transactionSpanId = transaction.contexts?.trace?.span_id;
const spans = transaction.spans ?? [];
const mysqlSpan = spans.find(span => span.description === 'SELECT 1 + 1 AS solution');
const listenerSpan = spans.find(span => span.description === 'listener-child');
const innerSpan = spans.find(span => span.description === 'inner-span');

expect(transactionSpanId).toBeDefined();
expect(mysqlSpan).toBeDefined();
expect(listenerSpan).toBeDefined();
expect(innerSpan).toBeDefined();

// The span created inside the stream `end` listener is parented to the transaction
// (the context active when the query was issued), not to the query span.
expect(listenerSpan?.parent_span_id).toBe(transactionSpanId);
expect(listenerSpan?.parent_span_id).not.toBe(mysqlSpan?.span_id);
expect(innerSpan?.parent_span_id).toBe(transactionSpanId);
},
})
.start()
.completed();
});
},
{ failsOnEsm },
);
});
}
});
Loading
Loading