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
7 changes: 7 additions & 0 deletions packages/instrumentation-tedious/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ Attributes collected:
| `net.peer.name` | Remote hostname or similar. |
| `net.peer.port` | Remote port number. |

### Trace Context Propagation

Database trace context propagation can be enabled by setting `enableTraceContextPropagation`to `true`.
This uses the [SET CONTEXT_INFO](https://learn.microsoft.com/en-us/sql/t-sql/statements/set-context-info-transact-sql?view=sql-server-ver16)
command to set [traceparent](https://www.w3.org/TR/trace-context/#traceparent-header)information
for the current connection, which results in **an additional round-trip to the database**.

## Useful links

- For more information on OpenTelemetry, visit: <https://opentelemetry.io/>
Expand Down
91 changes: 83 additions & 8 deletions packages/instrumentation-tedious/src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ import { PACKAGE_NAME, PACKAGE_VERSION } from './version';
const CURRENT_DATABASE = Symbol(
'opentelemetry.instrumentation-tedious.current-database'
);

export const INJECTED_CTX = Symbol(
'opentelemetry.instrumentation-tedious.context-info-injected'
);

const PATCHED_METHODS = [
'callProcedure',
'execSql',
Expand Down Expand Up @@ -89,7 +94,7 @@ export class TediousInstrumentation extends InstrumentationBase<TediousInstrumen
this._wrap(
ConnectionPrototype,
method,
this._patchQuery(method) as any
this._patchQuery(method, moduleExports) as any
);
}

Expand Down Expand Up @@ -127,11 +132,65 @@ export class TediousInstrumentation extends InstrumentationBase<TediousInstrumen
};
}

private _patchQuery(operation: string) {
private _buildTraceparent(span: api.Span): string {
const sc = span.spanContext();
const sampled =
(sc.traceFlags & api.TraceFlags.SAMPLED) === api.TraceFlags.SAMPLED
? '01'
: '00';
return `00-${sc.traceId}-${sc.spanId}-${sampled}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

To be an absolutely correct traceparent, with Trace Context Level 2, there are possibly other trace-flags to consider: https://www.w3.org/TR/trace-context-2/#trace-flags

I don't know if true, but I would hope there is some utility for creating the traceparent string from a SpanContext. Also, it looks like OTel JS doesn't yet implement the random-trace-id flag. So this code is fine now, but could get surprised later.

Copy link
Contributor

Choose a reason for hiding this comment

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

I didn't find a utility, but this building of the traceparent string is a little more future proof: https://github.com/open-telemetry/opentelemetry-js/blob/a2e3f4542f9a6a61c569d4f70fa49220da18cda0/packages/opentelemetry-core/src/trace/W3CTraceContextPropagator.ts#L84-L86

Perhaps use that handling of the traceFlags.

}

/**
* Fire a one-off `SET CONTEXT_INFO @opentelemetry_traceparent` on the same
* connection. Marks the request with INJECTED_CTX so our patch skips it.
*/
private _injectContextInfo(
connection: any,
tediousModule: typeof tedious,
traceparent: string
): Promise<void> {
return new Promise(resolve => {
try {
const sql = 'set context_info @opentelemetry_traceparent';
const req = new tediousModule.Request(sql, (_err: any) => {
resolve();
});
Object.defineProperty(req, INJECTED_CTX, { value: true });
const buf = Buffer.from(traceparent, 'utf8');
req.addParameter(
'opentelemetry_traceparent',
(tediousModule as any).TYPES.VarBinary,
buf,
{ length: buf.length }
);

connection.execSql(req);
} catch {
resolve();
}
});
}

private _shouldInjectFor(operation: string): boolean {
return (
operation === 'execSql' ||
operation === 'execSqlBatch' ||
operation === 'callProcedure' ||
operation === 'execute'
);
}

private _patchQuery(operation: string, tediousModule: typeof tedious) {
return (originalMethod: UnknownFunction): UnknownFunction => {
const thisPlugin = this;

function patchedMethod(this: ApproxConnection, request: ApproxRequest) {
// Skip our own injected request
if ((request as any)?.[INJECTED_CTX]) {
return originalMethod.apply(this, arguments as unknown as any[]);
}

if (!(request instanceof EventEmitter)) {
thisPlugin._diag.warn(
`Unexpected invocation of patched ${operation} method. Span not recorded`
Expand Down Expand Up @@ -207,12 +266,28 @@ export class TediousInstrumentation extends InstrumentationBase<TediousInstrumen
thisPlugin._diag.error('Expected request.callback to be a function');
}

return api.context.with(
api.trace.setSpan(api.context.active(), span),
originalMethod,
this,
...arguments
);
const runUserRequest = () => {
return api.context.with(
api.trace.setSpan(api.context.active(), span),
originalMethod,
this,
...arguments
);
};

const cfg = thisPlugin.getConfig();
const shouldInject =
cfg.enableTraceContextPropagation &&
thisPlugin._shouldInjectFor(operation);

if (!shouldInject) return runUserRequest();
Copy link
Contributor

Choose a reason for hiding this comment

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

The previous code and this code path will return the return value of the originalMethod() call, which is typically what is wanted when wrapping. The _injectContextInfo() code path below this will not. I gather that won't matter for this instrumentation because, at least currently, every patched method's return type is void.


const traceparent = thisPlugin._buildTraceparent(span);
thisPlugin
._injectContextInfo(this, tediousModule, traceparent)
.finally(runUserRequest);
Comment on lines +286 to +288
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a lint error to deal with here:

  286:9   error    Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator  @typescript-eslint/no-floating-promises

I think you could do this (untested):

        void thisPlugin
          ._injectContextInfo(this, tediousModule, traceparent)
          .finally(runUserRequest);


Copy link
Contributor

Choose a reason for hiding this comment

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

Separate question on the async behaviour here:

  • Without calling set context_info, a patched conn.execSql() will synchronously call the original execSql method before returning.
  • With the changes here and when set context_info is enabled, that is no longer true. Now, the original execSql will be called sometime later.

If I have JS code that does something like:

conn.execSql(sql1)
conn.execBulkLoad(sql2)

isn't there a sequencing problem here? I don't know tedious internals at all (e.g. whether and how it is queuing things). But with the example above, is it possible that the actual sequence of SQL executed is the following?

  1. set context_info
  2. sql2
  3. sql1

return;
}

Object.defineProperty(patchedMethod, 'length', {
Expand Down
10 changes: 8 additions & 2 deletions packages/instrumentation-tedious/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,11 @@
*/

import { InstrumentationConfig } from '@opentelemetry/instrumentation';

export type TediousInstrumentationConfig = InstrumentationConfig;
export interface TediousInstrumentationConfig extends InstrumentationConfig {
/**
* If true, injects the current DB span's W3C traceparent into SQL Server
* session state via `SET CONTEXT_INFO @opentelemetry_traceparent` (varbinary).
* Off by default to avoid the extra round-trip per request.
*/
enableTraceContextPropagation?: boolean;
}
13 changes: 9 additions & 4 deletions packages/instrumentation-tedious/test/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import * as assert from 'assert';
import { promisify } from 'util';
import type { Connection, Request, TYPES } from 'tedious';

import { INJECTED_CTX } from '../src/instrumentation';
type Method = keyof Connection & ('execSql' | 'execSqlBatch' | 'prepare');
export type tedious = {
Connection: typeof Connection;
Expand Down Expand Up @@ -67,7 +67,8 @@ export const makeApi = (tedious: tedious) => {
const query = (
connection: Connection,
params: string,
method: Method = 'execSql'
method: Method = 'execSql',
noTracking?: boolean
): Promise<any[]> => {
return new Promise((resolve, reject) => {
const result: any[] = [];
Expand All @@ -78,7 +79,9 @@ export const makeApi = (tedious: tedious) => {
resolve(result);
}
});

if (noTracking) {
Object.defineProperty(request, INJECTED_CTX, { value: true });
}
// request.on('returnValue', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'returnValue:'));
// request.on('error', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'error:'));
// request.on('row', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'row:'));
Expand Down Expand Up @@ -314,7 +317,9 @@ export const makeApi = (tedious: tedious) => {
if exists(SELECT * FROM sysobjects WHERE name='test_transact' AND xtype='U') DROP TABLE ${transaction.tableName};
if exists(SELECT * FROM sysobjects WHERE name='test_proced' AND xtype='U') DROP PROCEDURE ${storedProcedure.procedureName};
if exists(SELECT * FROM sys.databases WHERE name = 'temp_otel_db') DROP DATABASE temp_otel_db;
`.trim()
`.trim(),
'execSql',
true
);
};

Expand Down
64 changes: 62 additions & 2 deletions packages/instrumentation-tedious/test/instrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ const user = process.env.MSSQL_USER || 'sa';
const password = process.env.MSSQL_PASSWORD || 'mssql_passw0rd';

const instrumentation = new TediousInstrumentation();
instrumentation.enable();
instrumentation.disable();

const config: any = {
userName: user,
Expand Down Expand Up @@ -308,6 +306,68 @@ describe('tedious', () => {
table: 'test_bulk',
});
});

describe('trace context propagation via CONTEXT_INFO', () => {
function traceparentFromSpan(span: ReadableSpan) {
const sc = span.spanContext();
const flags = sc.traceFlags & 0x01 ? '01' : '00';
return `00-${sc.traceId}-${sc.spanId}-${flags}`;
}

beforeEach(() => {
instrumentation.setConfig({
enableTraceContextPropagation: true,
});
});

after(() => {
instrumentation.setConfig({ enableTraceContextPropagation: false });
});

it('injects DB-span traceparent for execSql', async function () {
const sql =
"SELECT REPLACE(CONVERT(varchar(128), CONTEXT_INFO()), CHAR(0), '') AS traceparent";
const result = await tedious.query(connection, sql);

const spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 1);
const expectedTp = traceparentFromSpan(spans[0]);
assert.strictEqual(
result[0],
expectedTp,
'CONTEXT_INFO traceparent should match DB span'
);
});

it('injects for execSqlBatch', async function () {
const batch = `
SELECT REPLACE(CONVERT(varchar(128), CONTEXT_INFO()), CHAR(0), '') AS tp;
SELECT 42;
`;
const result = await tedious.query(connection, batch, 'execSqlBatch');

assert.deepStrictEqual(result, [result[0], 42]);

const spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 1);
const expectedTp = traceparentFromSpan(spans[0]);
assert.strictEqual(result[0], expectedTp);
});

it('when disabled, CONTEXT_INFO stays empty', async function () {
instrumentation.setConfig({
enableTraceContextPropagation: false,
});

const [val] = await tedious.query(
connection,
"SELECT REPLACE(CONVERT(varchar(128), CONTEXT_INFO()), CHAR(0), '')"
);
assert.strictEqual(val, null);
const spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 1);
});
});
});

const assertMatch = (actual: string | undefined, expected: RegExp) => {
Expand Down
Loading