Skip to content

Commit f0842ac

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 0ea8a51 commit f0842ac

4 files changed

Lines changed: 291 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: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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+
function subscribeConnect(): void {
84+
diagnosticsChannel.tracingChannel(CHANNELS.TEDIOUS_CONNECT).start.subscribe(message => {
85+
const connection = (message as TediousConnectChannelContext).self;
86+
if (!connection) {
87+
return;
88+
}
89+
90+
setDatabase.call(connection, connection.config?.options?.database);
91+
92+
// Remove first in case `connect` runs more than once on the same connection.
93+
connection.removeListener('databaseChange', setDatabase);
94+
connection.on('databaseChange', setDatabase);
95+
connection.once('end', () => {
96+
connection.removeListener('databaseChange', setDatabase);
97+
});
98+
});
99+
}
100+
101+
function subscribeQuery(channelName: string, operation: string): void {
102+
diagnosticsChannel.tracingChannel(channelName).start.subscribe(message => {
103+
const data = message as TediousQueryChannelContext;
104+
const connection = data.self;
105+
const request = data.arguments[0];
106+
107+
// The vendored instrumentation only traced when the first argument is an `EventEmitter` (a `Request`
108+
// or `BulkLoad`); anything else is left untouched.
109+
if (!connection || !(request instanceof EventEmitter)) {
110+
return;
111+
}
112+
113+
let procCount = 0;
114+
let statementCount = 0;
115+
const incrementStatementCount = (): void => {
116+
statementCount++;
117+
};
118+
const incrementProcCount = (): void => {
119+
procCount++;
120+
};
121+
122+
const databaseName = connection[currentDatabaseSymbol];
123+
const sql = extractSql(request);
124+
125+
const attributes: SpanAttributes = {
126+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
127+
[DB_SYSTEM]: DB_SYSTEM_VALUE_MSSQL,
128+
[DB_NAME]: databaseName,
129+
// `>=4` uses the `authentication` object; older versions expose `userName` directly.
130+
[DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName,
131+
[DB_STATEMENT]: sql,
132+
[ATTR_DB_SQL_TABLE]: request.table,
133+
[NET_PEER_NAME]: connection.config?.server,
134+
[NET_PEER_PORT]: connection.config?.options?.port,
135+
};
136+
137+
const span = startInactiveSpan({
138+
name: getSpanName(operation, databaseName, sql, request.table),
139+
kind: SPAN_KIND.CLIENT,
140+
attributes,
141+
});
142+
143+
const endSpan = once((err?: { message?: string }): void => {
144+
request.removeListener('done', incrementStatementCount);
145+
request.removeListener('doneInProc', incrementStatementCount);
146+
request.removeListener('doneProc', incrementProcCount);
147+
request.removeListener('error', endSpan);
148+
connection.removeListener('end', endSpan);
149+
150+
span.setAttribute('tedious.procedure_count', procCount);
151+
span.setAttribute('tedious.statement_count', statementCount);
152+
if (err) {
153+
span.setStatus({ code: SPAN_STATUS_ERROR, message: err.message });
154+
}
155+
156+
span.end();
157+
});
158+
159+
request.on('done', incrementStatementCount);
160+
request.on('doneInProc', incrementStatementCount);
161+
request.on('doneProc', incrementProcCount);
162+
request.once('error', endSpan);
163+
connection.on('end', endSpan);
164+
165+
// tedious invokes `request.callback` when the request settles (passing the error, if any). Wrapping it
166+
// here (at `start`, before the method body dispatches) is the completion signal. A failed non-preparing
167+
// request reports its error only through this callback, not via an `'error'` event.
168+
if (typeof request.callback === 'function') {
169+
const originalCallback = request.callback;
170+
request.callback = function (this: unknown, ...args: unknown[]): unknown {
171+
endSpan(args[0] as { message?: string } | undefined);
172+
173+
return originalCallback.apply(this, args);
174+
};
175+
}
176+
});
177+
}
178+
179+
function extractSql(request: TediousRequest): string | undefined {
180+
// Required for <11.0.9: the SQL for a prepared statement is carried in the `stmt` parameter.
181+
if (request.sqlTextOrProcedure === 'sp_prepare' && request.parametersByName?.stmt?.value != null) {
182+
const value = request.parametersByName.stmt.value;
183+
184+
return typeof value === 'string' ? value : undefined;
185+
}
186+
187+
return request.sqlTextOrProcedure;
188+
}
189+
190+
/**
191+
* The span name is a low-cardinality label for the operation; the SDK's db-span inference later renames
192+
* the span description off `db.statement` when present. Mirrors the vendored OTel `getSpanName`.
193+
*/
194+
function getSpanName(
195+
operation: string,
196+
db: string | undefined,
197+
sql: string | undefined,
198+
bulkLoadTable: string | undefined,
199+
): string {
200+
if (operation === 'execBulkLoad' && bulkLoadTable && db) {
201+
return `${operation} ${bulkLoadTable} ${db}`;
202+
}
203+
if (operation === 'callProcedure') {
204+
// `sql` refers to the procedure name for `callProcedure`.
205+
return db ? `${operation} ${sql} ${db}` : `${operation} ${sql}`;
206+
}
207+
// Avoid `sql` in the general case because of its high cardinality.
208+
return db ? `${operation} ${db}` : operation;
209+
}
210+
211+
function once<Args extends unknown[]>(fn: (...args: Args) => void): (...args: Args) => void {
212+
let called = false;
213+
214+
return (...args: Args): void => {
215+
if (called) {
216+
return;
217+
}
218+
called = true;
219+
fn(...args);
220+
};
221+
}
222+
223+
const _tediousChannelIntegration = (() => {
224+
return {
225+
name: INTEGRATION_NAME,
226+
setupOnce() {
227+
// `tracingChannel` is unavailable before Node 18.19 so do nothing in that case.
228+
if (!diagnosticsChannel.tracingChannel) {
229+
return;
230+
}
231+
232+
DEBUG_BUILD && debug.log(`[orchestrion:tedious] subscribing to channel "${CHANNELS.TEDIOUS_EXEC_SQL}"`);
233+
234+
waitForTracingChannelBinding(() => {
235+
subscribeConnect();
236+
subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL, 'execSql');
237+
subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL_BATCH, 'execSqlBatch');
238+
subscribeQuery(CHANNELS.TEDIOUS_CALL_PROCEDURE, 'callProcedure');
239+
subscribeQuery(CHANNELS.TEDIOUS_EXEC_BULK_LOAD, 'execBulkLoad');
240+
subscribeQuery(CHANNELS.TEDIOUS_PREPARE, 'prepare');
241+
subscribeQuery(CHANNELS.TEDIOUS_EXECUTE, 'execute');
242+
});
243+
},
244+
};
245+
}) satisfies IntegrationFn;
246+
247+
/**
248+
* EXPERIMENTAL - orchestrion-driven tedious integration.
249+
*
250+
* Subscribes to the `orchestrion:tedious:*` diagnostics_channels that the orchestrion code transform
251+
* injects into tedious's `Connection` request methods (each traced as one db span) and `Connection.connect`
252+
* (active-database bookkeeping). Requires the orchestrion runtime hook or bundler plugin to be active.
253+
*/
254+
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
@@ -13,6 +13,7 @@ import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
1313
import { openaiChannelIntegration } from '../integrations/tracing-channel/openai';
1414
import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres';
1515
import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js';
16+
import { tediousChannelIntegration } from '../integrations/tracing-channel/tedious';
1617
import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai';
1718
import { expressChannelIntegration } from '../integrations/tracing-channel/express';
1819

@@ -30,6 +31,7 @@ export {
3031
openaiChannelIntegration,
3132
postgresChannelIntegration,
3233
postgresJsChannelIntegration,
34+
tediousChannelIntegration,
3335
vercelAiChannelIntegration,
3436
expressChannelIntegration,
3537
};
@@ -69,4 +71,5 @@ export const channelIntegrations = {
6971
expressIntegration: expressChannelIntegration,
7072
graphqlIntegration: graphqlDiagnosticsChannelIntegration,
7173
kafkajsIntegration: kafkajsChannelIntegration,
74+
tediousIntegration: tediousChannelIntegration,
7275
} as const;

0 commit comments

Comments
 (0)