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
Expand Up @@ -25,7 +25,8 @@ async function run() {
},
async _ => {
await connection.query('SELECT 1 + 1 AS solution');
await connection.query('SELECT NOW()', ['1', '2']);
await connection.query('SELECT ? as a, ? as b, NOW() as c', ['1', '2']);
await connection.query('SELECT ? AS scalar_value', 42);
// `execute` is instrumented the same way as `query`
await connection.execute('SELECT 42 AS answer');
// a failing query should produce a span with an error status
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { afterAll, expect } from 'vitest';
import { isOrchestrionEnabled } from '../../../utils';
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';

describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__dirname] }, () => {
afterAll(() => {
cleanupChildProcesses();
});

// With orchestrion injection enabled (`INJECT_ORCHESTRION`), the diagnostics-channel integration
// records the spans instead of the OTel patcher, so they carry a different `sentry.origin`.
const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.mysql2' : 'auto.db.otel.mysql2';

const EXPECTED_TRANSACTION = {
transaction: 'Test Transaction',
spans: expect.arrayContaining([
expect.objectContaining({
description: 'SELECT 1 + 1 AS solution',
op: 'db',
origin: 'auto.db.otel.mysql2',
origin: ORIGIN,
data: expect.objectContaining({
'db.system': 'mysql',
'db.statement': 'SELECT 1 + 1 AS solution',
Expand All @@ -21,23 +26,34 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
'db.user': 'root',
}),
}),
// bind values are left as `?` placeholders in `db.statement` (not inlined)
expect.objectContaining({
description: 'SELECT NOW()',
description: 'SELECT ? as a, ? as b, NOW() as c',

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.

Much more useful test 👍

op: 'db',
origin: 'auto.db.otel.mysql2',
origin: ORIGIN,
data: expect.objectContaining({
'db.system': 'mysql',
'db.statement': 'SELECT NOW()',
'db.statement': 'SELECT ? as a, ? as b, NOW() as c',
'net.peer.name': 'localhost',
'net.peer.port': 3306,
'db.user': 'root',
}),
}),
// a single non-array bind value is also left as a `?` placeholder in `db.statement`
expect.objectContaining({
description: 'SELECT ? AS scalar_value',
op: 'db',
origin: ORIGIN,
data: expect.objectContaining({
'db.system': 'mysql',
'db.statement': 'SELECT ? AS scalar_value',
}),
}),
// `execute` is instrumented the same way as `query`
expect.objectContaining({
description: 'SELECT 42 AS answer',
op: 'db',
origin: 'auto.db.otel.mysql2',
origin: ORIGIN,
data: expect.objectContaining({
'db.system': 'mysql',
'db.statement': 'SELECT 42 AS answer',
Expand All @@ -48,7 +64,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
description: 'SELECT * FROM does_not_exist',
op: 'db',
status: 'internal_error',
origin: 'auto.db.otel.mysql2',
origin: ORIGIN,
Comment thread
cursor[bot] marked this conversation as resolved.
data: expect.objectContaining({
'db.system': 'mysql',
'db.statement': 'SELECT * FROM does_not_exist',
Expand All @@ -58,7 +74,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
};

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => {
test('should auto-instrument `mysql` package without connection.connect()', { timeout: 75_000 }, async () => {
test('should auto-instrument `mysql2` package without connection.connect()', { timeout: 75_000 }, async () => {
await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
});
});
Expand Down
164 changes: 164 additions & 0 deletions packages/server-utils/src/integrations/tracing-channel/mysql2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import * as diagnosticsChannel from 'node:diagnostics_channel';
import type { IntegrationFn, SpanAttributes } from '@sentry/core';
import {
defineIntegration,
isObjectLike,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SPAN_KIND,
startInactiveSpan,
waitForTracingChannelBinding,
} from '@sentry/core';
import { subscribeMysql2DiagnosticChannels } from '../../mysql2/mysql2-dc-subscriber';
import type { ChannelName } from '../../orchestrion/channels';
import { CHANNELS } from '../../orchestrion/channels';
import { bindTracingChannelToSpan } from '../../tracing-channel';
import {
DB_NAME,
DB_STATEMENT,
DB_SYSTEM,
DB_USER,
NET_PEER_NAME,
NET_PEER_PORT,
} from '@sentry/conventions/attributes';

const INTEGRATION_NAME = 'Mysql2' as const;
const ORIGIN = 'auto.db.orchestrion.mysql2';
const DB_SYSTEM_VALUE_MYSQL = 'mysql';

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.

Note: I decided to drop db.connection_string as we just build this from other attributes, so it is redundant information. we have this in some other places, e.g. mongo, postgres, ioredis, we can either drop those while migrating or also drop it in v11, both are fine IMHO.


/**
* The shape orchestrion's transform attaches to the tracing-channel `context` object. Documented here
* rather than imported because orchestrion's runtime doesn't export it.
*/
interface Mysql2QueryChannelContext {
// The live args array passed to the wrapped `query`/`execute` call: `arguments[0]` is the SQL (a
// string, `Query`, or `{ sql, values }`), `arguments[1]` is the values array or a callback.
arguments: unknown[];
self?: Mysql2Connection;
result?: unknown;
error?: unknown;
}

interface Mysql2ConnectionConfig {
host?: string;
port?: number | string;
database?: string;
user?: string;
// Pool connections nest the real config one level deeper.
connectionConfig?: Mysql2ConnectionConfig;
}

interface Mysql2Connection {
config?: Mysql2ConnectionConfig;
}

/**
* Orchestrion-driven mysql2 integration.
*
* Subscribes to:
* - the `orchestrion:mysql2:query`/`:execute` channels the code transform injects into mysql2's
* `query`/`execute` (mysql2 `< 3.20.0`), and
* - mysql2's native `node:diagnostics_channel` tracing channels (mysql2 `>= 3.20.0`), which the
* transform intentionally leaves alone.
*
* The two version ranges never overlap, so no query is double-counted. Requires the orchestrion
* runtime hook or bundler plugin to be active — wire that up via
* `experimentalUseDiagnosticsChannelInjection`.
*/
function instrumentMysql2(): void {
// mysql2 >= 3.20.0: native diagnostics_channel path (inert on older versions, which never publish).
subscribeMysql2DiagnosticChannels(diagnosticsChannel.tracingChannel);

// mysql2 < 3.20.0: orchestrion-injected channels (inert on >= 3.20.0, which we don't transform).
subscribeQueryChannel(CHANNELS.MYSQL2_QUERY);
subscribeQueryChannel(CHANNELS.MYSQL2_EXECUTE);
}

function subscribeQueryChannel(channelName: ChannelName): void {
bindTracingChannelToSpan(
diagnosticsChannel.tracingChannel<Mysql2QueryChannelContext>(channelName),
data => {
const statement = getQueryText(data.arguments);

return startInactiveSpan({
name: statement ?? 'mysql2.query',
kind: SPAN_KIND.CLIENT,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',
// oxlint-disable-next-line typescript/no-deprecated
[DB_SYSTEM]: DB_SYSTEM_VALUE_MYSQL,
...getConnectionAttributes(data.self?.config),
// oxlint-disable-next-line typescript/no-deprecated
[DB_STATEMENT]: statement || undefined,
},
});
},
{ requiresParentSpan: true },
);
}

/**
* Render the `db.statement` from the wrapped call's first argument.
*/
function getQueryText(args: unknown[]): string | undefined {
return extractSql(args[0]);
}

function extractSql(firstArg: unknown): string | undefined {
if (typeof firstArg === 'string') {
return firstArg;
}
if (isObjectLike(firstArg) && 'sql' in firstArg) {
const sql = (firstArg as { sql?: unknown }).sql;
return typeof sql === 'string' ? sql : undefined;
}
return undefined;
}

function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): SpanAttributes {
// Pool connections nest the real config under `.connectionConfig`; single connections expose it
// directly. Matches `@opentelemetry/instrumentation-mysql2`.
const { host, port, database, user } = config?.connectionConfig ?? config ?? {};
const portNumber = typeof port === 'string' ? parseInt(port, 10) : port;
const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber);

return {
// oxlint-disable-next-line typescript/no-deprecated
[DB_NAME]: database || undefined,
[DB_USER]: user || undefined,
// oxlint-disable-next-line typescript/no-deprecated
[NET_PEER_NAME]: host || undefined,
// oxlint-disable-next-line typescript/no-deprecated
[NET_PEER_PORT]: portIsNumber ? portNumber : undefined,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing JDBC connection string attribute

Medium Severity

The orchestrion mysql2 span attributes omit db.connection_string, while the vendored @opentelemetry/instrumentation-mysql2 patcher and the orchestrion mysql integration always emit a JDBC-style connection string. Opt-in orchestrion users lose that attribute on query and execute spans for mysql2 versions below 3.20.0.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be9e15d. Configure here.

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.

on purpose

}

const _mysql2ChannelIntegration = (() => {

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.

This integration probably also needs to be added to Deno: https://github.com/getsentry/sentry-javascript/tree/develop/packages/deno/src/integrations

(if mysql2 is expected to work on Deno)

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.

And if this needs a Deno wrapper: this should have a guard (usually done with a closure like in redis) so it's not running twice.

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.

I would keep this in a separate/follow up PR IMHO, we do not need to do this at the same time :)

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.

I wonder if rather than one-off each Deno integration, there's a way to have the Deno SDK automatically slurp these all up. They're all pretty much identical, after all, and exported by server-utils. 🤔

return {
name: INTEGRATION_NAME,
setupOnce() {
// `tracingChannel` is unavailable before Node 18.19 so do nothing in that case.
if (!diagnosticsChannel.tracingChannel) {
return;
}

waitForTracingChannelBinding(() => {
instrumentMysql2();
});
},
};
}) satisfies IntegrationFn;

/**
* Orchestrion-driven mysql2 integration.
*
* Adds Sentry tracing instrumentation for the [mysql2](https://www.npmjs.com/package/mysql2) library
* via diagnostics-channel injection. See {@link instrumentMysql2} for how the two mysql2 version
* ranges are covered.
*
* Known limitation vs. the OTel integration it replaces: the callback-less streaming form
* (`connection.query(sql).on('result', ...)`) is not traced — see the `mysql2` orchestrion config for
* why. The callback and promise forms (the common case) are fully instrumented.
*/
export const mysql2ChannelIntegration = defineIntegration(_mysql2ChannelIntegration);
48 changes: 45 additions & 3 deletions packages/server-utils/src/orchestrion/config/mysql2.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,48 @@
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';

// TODO: Stub for the `mysql2` orchestrion integration (ports `@opentelemetry/instrumentation-mysql2`).
export const mysql2Config: InstrumentationConfig[] = [];
// Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection
// prototype) to orchestrion channel injection.
//
// Two file layouts across the supported range:
// - `< 3.11.5` → `class Connection` with `query`/`execute` in `lib/connection.js`
// - `>= 3.11.5` → the methods moved to `class BaseConnection` in `lib/base/connection.js`
//
// Gated to `< 3.20.0`: from 3.20.0 mysql2 publishes to `node:diagnostics_channel` natively, and
// that path is instrumented by `subscribeMysql2DiagnosticChannels` instead. Injecting there too
// would emit two spans per query.
//
// `Callback` traces the callback shapes: `query(sql, cb)`, `query(sql, values, cb)`, and — since the
// promise API funnels through the callback form — `await connection.query(sql)`. `execute` never
// delegates to `query` internally, so instrumenting both is safe.
//
// NOT `Auto`: `Auto`'s no-callback branch probes the return value for `.then`, but mysql2's callback-
// less `query(sql)` returns a `Query` whose `.then()` throws (mysql2's "use the promise wrapper"
// guard) — so `Auto` would crash streamed queries. `Callback` leaves that shape untouched (a rare,
// row-streaming use that consumes the emitter's events), the tradeoff being it isn't traced.
export const mysql2Config = [
{
channelName: 'query',
module: { name: 'mysql2', versionRange: '>=1.4.2 <3.11.5', filePath: 'lib/connection.js' },
functionQuery: { className: 'Connection', methodName: 'query', kind: 'Callback' },
},
{
channelName: 'execute',
module: { name: 'mysql2', versionRange: '>=1.4.2 <3.11.5', filePath: 'lib/connection.js' },
functionQuery: { className: 'Connection', methodName: 'execute', kind: 'Callback' },
},
{
channelName: 'query',
module: { name: 'mysql2', versionRange: '>=3.11.5 <3.20.0', filePath: 'lib/base/connection.js' },
functionQuery: { className: 'BaseConnection', methodName: 'query', kind: 'Callback' },
},
{
channelName: 'execute',
module: { name: 'mysql2', versionRange: '>=3.11.5 <3.20.0', filePath: 'lib/base/connection.js' },
functionQuery: { className: 'BaseConnection', methodName: 'execute', kind: 'Callback' },
},
] satisfies InstrumentationConfig[];

export const mysql2Channels = {} as const;
export const mysql2Channels = {
MYSQL2_QUERY: 'orchestrion:mysql2:query',
MYSQL2_EXECUTE: 'orchestrion:mysql2:execute',
} as const;
3 changes: 3 additions & 0 deletions packages/server-utils/src/orchestrion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ioredisChannelIntegration } from '../integrations/tracing-channel/iored
import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs';
import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer';
import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2';
import { openaiChannelIntegration } from '../integrations/tracing-channel/openai';
import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres';
import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js';
Expand All @@ -34,6 +35,7 @@ export {
kafkajsChannelIntegration,
lruMemoizerChannelIntegration,
mysqlChannelIntegration,
mysql2ChannelIntegration,
openaiChannelIntegration,
postgresChannelIntegration,
postgresJsChannelIntegration,
Expand Down Expand Up @@ -75,6 +77,7 @@ export const channelIntegrations = {
postgresIntegration: postgresChannelIntegration,
postgresJsIntegration: postgresJsChannelIntegration,
mysqlIntegration: mysqlChannelIntegration,
mysql2Integration: mysql2ChannelIntegration,
genericPoolIntegration: genericPoolChannelIntegration,
lruMemoizerIntegration: lruMemoizerChannelIntegration,
openaiIntegration: openaiChannelIntegration,
Expand Down
Loading