Skip to content

Commit ca2f97c

Browse files
committed
feat(node): Rewrite tedious instrumentation to orchestrion tracing channels
Migrate the tedious integration off the vendored `InstrumentationBase` monkey-patch onto a `node:diagnostics_channel` subscriber whose channels are injected by the orchestrion code transform. The OTel path stays as the fallback when orchestrion isn't injected. tedious is a default performance integration, so it uses the central `channelIntegrations` swap: `_init` filters the OTel `Tedious` integration out of the defaults by name and appends the channel one. No per-integration node code. The subscriber wraps the six `Connection` request methods (one db span each) and `Connection.connect` (active-database bookkeeping). Each method returns synchronously while the request settles later via its callback/events, so the subscriber owns span-ending: it wraps `request.callback` and listens for the request `error` and connection `end` events, mirroring the vendored OTel instrumentation. Fixes #20766
1 parent 3204ba5 commit ca2f97c

4 files changed

Lines changed: 297 additions & 6 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import { afterAll, expect } from 'vitest';
2+
import { isOrchestrionEnabled } from '../../../utils';
23
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
34

45
describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [__dirname] }, () => {
6+
const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.tedious' : 'auto.db.otel.tedious';
7+
58
afterAll(() => {
69
cleanupChildProcesses();
710
});
811

912
const dbSpan = (overrides: Record<string, unknown>) =>
1013
expect.objectContaining({
1114
op: 'db',
12-
origin: 'auto.db.otel.tedious',
15+
origin: ORIGIN,
1316
data: expect.objectContaining({
14-
'sentry.origin': 'auto.db.otel.tedious',
17+
'sentry.origin': ORIGIN,
1518
'sentry.op': 'db',
1619
'db.system': 'mssql',
1720
'db.name': 'master',
@@ -33,7 +36,7 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_
3336
expect.objectContaining({
3437
description: 'execBulkLoad test_bulk master',
3538
op: 'db',
36-
origin: 'auto.db.otel.tedious',
39+
origin: ORIGIN,
3740
status: 'ok',
3841
data: expect.objectContaining({ 'db.sql.table': 'test_bulk' }),
3942
}),
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// The `@sentry/conventions` db/net attribute keys are deprecated (superseded by newer semconv), but we
2+
// emit them deliberately to preserve parity with what `@opentelemetry/instrumentation-tedious` produced.
3+
/* oxlint-disable typescript/no-deprecated */
4+
5+
import { EventEmitter } from 'node:events';
6+
import * as diagnosticsChannel from 'node:diagnostics_channel';
7+
import type { IntegrationFn, SpanAttributes } from '@sentry/core';
8+
import {
9+
debug,
10+
defineIntegration,
11+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
12+
SPAN_KIND,
13+
SPAN_STATUS_ERROR,
14+
startInactiveSpan,
15+
waitForTracingChannelBinding,
16+
} from '@sentry/core';
17+
import {
18+
DB_NAME,
19+
DB_STATEMENT,
20+
DB_SYSTEM,
21+
DB_USER,
22+
NET_PEER_NAME,
23+
NET_PEER_PORT,
24+
} from '@sentry/conventions/attributes';
25+
import { DEBUG_BUILD } from '../../debug-build';
26+
import { CHANNELS } from '../../orchestrion/channels';
27+
28+
// NOTE: this uses the same name as the OTel integration by design. When orchestrion injection is active,
29+
// `_init` swaps the OTel `Tedious` integration out of the defaults and appends this one (matched by name).
30+
const INTEGRATION_NAME = 'Tedious' as const;
31+
const ORIGIN = 'auto.db.orchestrion.tedious';
32+
33+
// OTel db/net semantic-convention values/keys not exported by `@sentry/conventions`, inlined to match
34+
// what `@opentelemetry/instrumentation-tedious` emitted.
35+
const DB_SYSTEM_VALUE_MSSQL = 'mssql';
36+
const ATTR_DB_SQL_TABLE = 'db.sql.table';
37+
38+
// Tracks the connection's active database (updated on `databaseChange`), read into `db.name` when a query
39+
// runs. Mirrors the `CURRENT_DATABASE` symbol the vendored OTel instrumentation stashed on the connection.
40+
const currentDatabaseSymbol = Symbol('sentry.orchestrion.tedious.current-database');
41+
42+
type UnknownFunction = (...args: unknown[]) => unknown;
43+
44+
interface TediousConnectionConfig {
45+
server?: string;
46+
userName?: string;
47+
authentication?: { options?: { userName?: string } };
48+
options?: { database?: string; port?: number };
49+
}
50+
51+
interface TediousConnection extends EventEmitter {
52+
config?: TediousConnectionConfig;
53+
[currentDatabaseSymbol]?: string;
54+
}
55+
56+
interface TediousRequest extends EventEmitter {
57+
sqlTextOrProcedure?: string;
58+
callback?: UnknownFunction;
59+
table?: string;
60+
parametersByName?: Record<string, { value?: unknown } | undefined>;
61+
}
62+
63+
/** Context orchestrion attaches to the query channels (wrapping the `Connection` request methods). */
64+
interface TediousQueryChannelContext {
65+
// `arguments[0]` is the `Request` (or `BulkLoad` for `execBulkLoad`), both `EventEmitter`s.
66+
arguments: [TediousRequest?, ...unknown[]];
67+
self?: TediousConnection;
68+
moduleVersion?: string;
69+
}
70+
71+
/** Context orchestrion attaches to the `Connection.connect` channel. */
72+
interface TediousConnectChannelContext {
73+
arguments: unknown[];
74+
self?: TediousConnection;
75+
}
76+
77+
// Used both to seed the initial database and as the `databaseChange` listener, where `this` is the
78+
// connection (a non-arrow listener). Keeping one shared reference lets `removeListener` find it again.
79+
function setDatabase(this: TediousConnection, databaseName: string | undefined): void {
80+
Object.defineProperty(this, currentDatabaseSymbol, { value: databaseName, writable: true, configurable: true });
81+
}
82+
83+
// The `end` cleanup listener, where `this` is the connection (a non-arrow listener). Named (like
84+
// `setDatabase`) so repeated `connect` calls can `removeListener` it rather than accumulate anonymous ones.
85+
function removeDatabaseListener(this: TediousConnection): void {
86+
this.removeListener('databaseChange', setDatabase);
87+
}
88+
89+
function subscribeConnect(): void {
90+
diagnosticsChannel.tracingChannel(CHANNELS.TEDIOUS_CONNECT).start.subscribe(message => {
91+
const connection = (message as TediousConnectChannelContext).self;
92+
if (!connection) {
93+
return;
94+
}
95+
96+
setDatabase.call(connection, connection.config?.options?.database);
97+
98+
// Remove first in case `connect` runs more than once on the same connection, so neither listener
99+
// accumulates across reconnects.
100+
connection.removeListener('databaseChange', setDatabase);
101+
connection.on('databaseChange', setDatabase);
102+
connection.removeListener('end', removeDatabaseListener);
103+
connection.once('end', removeDatabaseListener);
104+
});
105+
}
106+
107+
function subscribeQuery(channelName: string, operation: string): void {
108+
diagnosticsChannel.tracingChannel(channelName).start.subscribe(message => {
109+
const data = message as TediousQueryChannelContext;
110+
const connection = data.self;
111+
const request = data.arguments[0];
112+
113+
// The vendored instrumentation only traced when the first argument is an `EventEmitter` (a `Request`
114+
// or `BulkLoad`); anything else is left untouched.
115+
if (!connection || !(request instanceof EventEmitter)) {
116+
return;
117+
}
118+
119+
let procCount = 0;
120+
let statementCount = 0;
121+
const incrementStatementCount = (): void => {
122+
statementCount++;
123+
};
124+
const incrementProcCount = (): void => {
125+
procCount++;
126+
};
127+
128+
const databaseName = connection[currentDatabaseSymbol];
129+
const sql = extractSql(request);
130+
131+
const attributes: SpanAttributes = {
132+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
133+
[DB_SYSTEM]: DB_SYSTEM_VALUE_MSSQL,
134+
[DB_NAME]: databaseName,
135+
// `>=4` uses the `authentication` object; older versions expose `userName` directly.
136+
[DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName,
137+
[DB_STATEMENT]: sql,
138+
[ATTR_DB_SQL_TABLE]: request.table,
139+
[NET_PEER_NAME]: connection.config?.server,
140+
[NET_PEER_PORT]: connection.config?.options?.port,
141+
};
142+
143+
const span = startInactiveSpan({
144+
name: getSpanName(operation, databaseName, sql, request.table),
145+
kind: SPAN_KIND.CLIENT,
146+
attributes,
147+
});
148+
149+
const endSpan = once((err?: { message?: string }): void => {
150+
request.removeListener('done', incrementStatementCount);
151+
request.removeListener('doneInProc', incrementStatementCount);
152+
request.removeListener('doneProc', incrementProcCount);
153+
request.removeListener('error', endSpan);
154+
connection.removeListener('end', endSpan);
155+
156+
span.setAttribute('tedious.procedure_count', procCount);
157+
span.setAttribute('tedious.statement_count', statementCount);
158+
if (err) {
159+
span.setStatus({ code: SPAN_STATUS_ERROR, message: err.message });
160+
}
161+
162+
span.end();
163+
});
164+
165+
request.on('done', incrementStatementCount);
166+
request.on('doneInProc', incrementStatementCount);
167+
request.on('doneProc', incrementProcCount);
168+
request.once('error', endSpan);
169+
connection.on('end', endSpan);
170+
171+
// tedious invokes `request.callback` when the request settles (passing the error, if any). Wrapping it
172+
// here (at `start`, before the method body dispatches) is the completion signal. A failed non-preparing
173+
// request reports its error only through this callback, not via an `'error'` event.
174+
if (typeof request.callback === 'function') {
175+
const originalCallback = request.callback;
176+
request.callback = function (this: unknown, ...args: unknown[]): unknown {
177+
endSpan(args[0] as { message?: string } | undefined);
178+
179+
return originalCallback.apply(this, args);
180+
};
181+
}
182+
});
183+
}
184+
185+
function extractSql(request: TediousRequest): string | undefined {
186+
// Required for <11.0.9: the SQL for a prepared statement is carried in the `stmt` parameter.
187+
if (request.sqlTextOrProcedure === 'sp_prepare' && request.parametersByName?.stmt?.value != null) {
188+
const value = request.parametersByName.stmt.value;
189+
190+
return typeof value === 'string' ? value : undefined;
191+
}
192+
193+
return request.sqlTextOrProcedure;
194+
}
195+
196+
/**
197+
* The span name is a low-cardinality label for the operation; the SDK's db-span inference later renames
198+
* the span description off `db.statement` when present. Mirrors the vendored OTel `getSpanName`.
199+
*/
200+
function getSpanName(
201+
operation: string,
202+
db: string | undefined,
203+
sql: string | undefined,
204+
bulkLoadTable: string | undefined,
205+
): string {
206+
if (operation === 'execBulkLoad' && bulkLoadTable && db) {
207+
return `${operation} ${bulkLoadTable} ${db}`;
208+
}
209+
if (operation === 'callProcedure') {
210+
// `sql` refers to the procedure name for `callProcedure`.
211+
return db ? `${operation} ${sql} ${db}` : `${operation} ${sql}`;
212+
}
213+
// Avoid `sql` in the general case because of its high cardinality.
214+
return db ? `${operation} ${db}` : operation;
215+
}
216+
217+
function once<Args extends unknown[]>(fn: (...args: Args) => void): (...args: Args) => void {
218+
let called = false;
219+
220+
return (...args: Args): void => {
221+
if (called) {
222+
return;
223+
}
224+
called = true;
225+
fn(...args);
226+
};
227+
}
228+
229+
const _tediousChannelIntegration = (() => {
230+
return {
231+
name: INTEGRATION_NAME,
232+
setupOnce() {
233+
// `tracingChannel` is unavailable before Node 18.19 so do nothing in that case.
234+
if (!diagnosticsChannel.tracingChannel) {
235+
return;
236+
}
237+
238+
DEBUG_BUILD && debug.log(`[orchestrion:tedious] subscribing to channel "${CHANNELS.TEDIOUS_EXEC_SQL}"`);
239+
240+
waitForTracingChannelBinding(() => {
241+
subscribeConnect();
242+
subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL, 'execSql');
243+
subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL_BATCH, 'execSqlBatch');
244+
subscribeQuery(CHANNELS.TEDIOUS_CALL_PROCEDURE, 'callProcedure');
245+
subscribeQuery(CHANNELS.TEDIOUS_EXEC_BULK_LOAD, 'execBulkLoad');
246+
subscribeQuery(CHANNELS.TEDIOUS_PREPARE, 'prepare');
247+
subscribeQuery(CHANNELS.TEDIOUS_EXECUTE, 'execute');
248+
});
249+
},
250+
};
251+
}) satisfies IntegrationFn;
252+
253+
/**
254+
* EXPERIMENTAL - orchestrion-driven tedious integration.
255+
*
256+
* Subscribes to the `orchestrion:tedious:*` diagnostics_channels that the orchestrion code transform
257+
* injects into tedious's `Connection` request methods (each traced as one db span) and `Connection.connect`
258+
* (active-database bookkeeping). Requires the orchestrion runtime hook or bundler plugin to be active.
259+
*/
260+
export const tediousChannelIntegration = defineIntegration(_tediousChannelIntegration);
Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
11
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';
22

3-
// TODO: Stub for the `tedious` orchestrion integration (ports `@opentelemetry/instrumentation-tedious`).
4-
export const tediousConfig: InstrumentationConfig[] = [];
3+
const MODULE_NAME = 'tedious';
54

6-
export const tediousChannels = {} as const;
5+
// `Connection` has lived in `lib/connection.js` across the whole supported range (matches the vendored
6+
// OTel `supportedVersions`). Orchestrion never matches a file that doesn't exist, so a single entry is
7+
// safe even for versions that shipped extra layouts.
8+
const FILE_PATH = 'lib/connection.js';
9+
const VERSION_RANGE = '>=1.11.0 <20';
10+
11+
// `Connection` methods that dispatch a request (each traced as one db span) plus `connect`, which the
12+
// subscriber wraps for bookkeeping only (tracking the connection's active database, read into `db.name`).
13+
// All return synchronously; the request completes later via its callback/events, so the subscriber owns
14+
// span-ending rather than the channel lifecycle.
15+
const METHODS = ['connect', 'execSql', 'execSqlBatch', 'callProcedure', 'execBulkLoad', 'prepare', 'execute'] as const;
16+
17+
export const tediousConfig: InstrumentationConfig[] = METHODS.map(methodName => ({
18+
channelName: methodName,
19+
module: { name: MODULE_NAME, versionRange: VERSION_RANGE, filePath: FILE_PATH },
20+
functionQuery: { className: 'Connection', methodName, kind: 'Sync' },
21+
}));
22+
23+
export const tediousChannels = {
24+
TEDIOUS_CONNECT: 'orchestrion:tedious:connect',
25+
TEDIOUS_EXEC_SQL: 'orchestrion:tedious:execSql',
26+
TEDIOUS_EXEC_SQL_BATCH: 'orchestrion:tedious:execSqlBatch',
27+
TEDIOUS_CALL_PROCEDURE: 'orchestrion:tedious:callProcedure',
28+
TEDIOUS_EXEC_BULK_LOAD: 'orchestrion:tedious:execBulkLoad',
29+
TEDIOUS_PREPARE: 'orchestrion:tedious:prepare',
30+
TEDIOUS_EXECUTE: 'orchestrion:tedious:execute',
31+
} as const;

packages/server-utils/src/orchestrion/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
1414
import { openaiChannelIntegration } from '../integrations/tracing-channel/openai';
1515
import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres';
1616
import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js';
17+
import { tediousChannelIntegration } from '../integrations/tracing-channel/tedious';
1718
import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai';
1819
import { expressChannelIntegration } from '../integrations/tracing-channel/express';
1920

@@ -35,6 +36,7 @@ export {
3536
openaiChannelIntegration,
3637
postgresChannelIntegration,
3738
postgresJsChannelIntegration,
39+
tediousChannelIntegration,
3840
vercelAiChannelIntegration,
3941
expressChannelIntegration,
4042
};
@@ -79,4 +81,5 @@ export const channelIntegrations = {
7981
expressIntegration: expressChannelIntegration,
8082
graphqlIntegration: graphqlDiagnosticsChannelIntegration,
8183
kafkajsIntegration: kafkajsChannelIntegration,
84+
tediousIntegration: tediousChannelIntegration,
8285
} as const;

0 commit comments

Comments
 (0)