Skip to content

Commit a0003e7

Browse files
authored
feat: add sqlcommenter comment with trace context to queries in pg instrumentation (open-telemetry#1286)
1 parent fef44f4 commit a0003e7

File tree

7 files changed

+317
-1
lines changed

7 files changed

+317
-1
lines changed

plugins/node/opentelemetry-instrumentation-pg/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ PostgreSQL instrumentation has few options available to choose from. You can set
4848
| ------- | ---- | ----------- |
4949
| [`enhancedDatabaseReporting`](./src/types.ts#L30) | `boolean` | If true, additional information about query parameters and results will be attached (as `attributes`) to spans representing database operations |
5050
| `responseHook` | `PgInstrumentationExecutionResponseHook` (function) | Function for adding custom attributes from db response |
51+
| `requireParentSpan` | `boolean` | If true, requires a parent span to create new spans (default false) |
52+
| `addSqlCommenterCommentToQueries` | `boolean` | If true, adds [sqlcommenter](https://github.com/open-telemetry/opentelemetry-sqlcommenter) specification compliant comment to queries with tracing context (default false). _NOTE: A comment will not be added to queries that already contain `--` or `/* ... */` in them, even if these are not actually part of comments_ |
5153

5254
## Useful links
5355

plugins/node/opentelemetry-instrumentation-pg/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,21 @@
6060
"@opentelemetry/sdk-trace-node": "^1.8.0",
6161
"@types/mocha": "7.0.2",
6262
"@types/node": "18.11.7",
63+
"@types/sinon": "10.0.2",
6364
"cross-env": "7.0.3",
6465
"gts": "3.1.0",
6566
"mocha": "7.2.0",
6667
"nyc": "15.1.0",
6768
"pg": "8.7.1",
6869
"pg-pool": "3.4.1",
6970
"rimraf": "3.0.2",
71+
"sinon": "14.0.0",
7072
"test-all-versions": "5.0.1",
7173
"ts-mocha": "10.0.0",
7274
"typescript": "4.3.5"
7375
},
7476
"dependencies": {
77+
"@opentelemetry/core": "^1.8.0",
7578
"@opentelemetry/instrumentation": "^0.34.0",
7679
"@opentelemetry/semantic-conventions": "^1.0.0",
7780
"@types/pg": "8.6.1",

plugins/node/opentelemetry-instrumentation-pg/src/instrumentation.ts

+16
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export class PgInstrumentation extends InstrumentationBase {
178178
// Handle different client.query(...) signatures
179179
if (typeof args[0] === 'string') {
180180
const query = args[0];
181+
181182
if (args.length > 1 && args[1] instanceof Array) {
182183
const params = args[1];
183184
span = utils.handleParameterizedQuery.call(
@@ -195,14 +196,29 @@ export class PgInstrumentation extends InstrumentationBase {
195196
query
196197
);
197198
}
199+
200+
if (plugin.getConfig().addSqlCommenterCommentToQueries) {
201+
// Modify the query with a tracing comment
202+
args[0] = utils.addSqlCommenterComment(span, args[0]);
203+
}
198204
} else if (typeof args[0] === 'object') {
199205
const queryConfig = args[0] as NormalizedQueryConfig;
206+
200207
span = utils.handleConfigQuery.call(
201208
this,
202209
plugin.tracer,
203210
plugin.getConfig(),
204211
queryConfig
205212
);
213+
214+
if (plugin.getConfig().addSqlCommenterCommentToQueries) {
215+
// Copy the query config instead of writing to args[0].text so that we don't modify user's
216+
// original query config reference
217+
args[0] = {
218+
...queryConfig,
219+
text: utils.addSqlCommenterComment(span, queryConfig.text),
220+
};
221+
}
206222
} else {
207223
return utils.handleInvalidQuery.call(
208224
this,

plugins/node/opentelemetry-instrumentation-pg/src/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,10 @@ export interface PgInstrumentationConfig extends InstrumentationConfig {
4646
* @default false
4747
*/
4848
requireParentSpan?: boolean;
49+
50+
/**
51+
* If true, queries are modified to also include a comment with
52+
* the tracing context, following the {@link https://github.com/open-telemetry/opentelemetry-sqlcommenter sqlcommenter} format
53+
*/
54+
addSqlCommenterCommentToQueries?: boolean;
4955
}

plugins/node/opentelemetry-instrumentation-pg/src/utils.ts

+69
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import {
2424
diag,
2525
INVALID_SPAN_CONTEXT,
2626
Attributes,
27+
defaultTextMapSetter,
28+
ROOT_CONTEXT,
2729
} from '@opentelemetry/api';
30+
import { W3CTraceContextPropagator } from '@opentelemetry/core';
2831
import { AttributeNames } from './enums/AttributeNames';
2932
import {
3033
SemanticAttributes,
@@ -289,3 +292,69 @@ export function patchClientConnectCallback(
289292
cb.call(this, err);
290293
};
291294
}
295+
296+
// NOTE: This function currently is returning false-positives
297+
// in cases where comment characters appear in string literals
298+
// ("SELECT '-- not a comment';" would return true, although has no comment)
299+
function hasValidSqlComment(query: string): boolean {
300+
const indexOpeningDashDashComment = query.indexOf('--');
301+
if (indexOpeningDashDashComment >= 0) {
302+
return true;
303+
}
304+
305+
const indexOpeningSlashComment = query.indexOf('/*');
306+
if (indexOpeningSlashComment < 0) {
307+
return false;
308+
}
309+
310+
const indexClosingSlashComment = query.indexOf('*/');
311+
return indexOpeningDashDashComment < indexClosingSlashComment;
312+
}
313+
314+
// sqlcommenter specification (https://google.github.io/sqlcommenter/spec/#value-serialization)
315+
// expects us to URL encode based on the RFC 3986 spec (https://en.wikipedia.org/wiki/Percent-encoding),
316+
// but encodeURIComponent does not handle some characters correctly (! ' ( ) *),
317+
// which means we need special handling for this
318+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
319+
function fixedEncodeURIComponent(str: string) {
320+
return encodeURIComponent(str).replace(
321+
/[!'()*]/g,
322+
c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
323+
);
324+
}
325+
326+
export function addSqlCommenterComment(span: Span, query: string): string {
327+
if (typeof query !== 'string' || query.length === 0) {
328+
return query;
329+
}
330+
331+
// As per sqlcommenter spec we shall not add a comment if there already is a comment
332+
// in the query
333+
if (hasValidSqlComment(query)) {
334+
return query;
335+
}
336+
337+
const propagator = new W3CTraceContextPropagator();
338+
const headers: { [key: string]: string } = {};
339+
propagator.inject(
340+
trace.setSpan(ROOT_CONTEXT, span),
341+
headers,
342+
defaultTextMapSetter
343+
);
344+
345+
// sqlcommenter spec requires keys in the comment to be sorted lexicographically
346+
const sortedKeys = Object.keys(headers).sort();
347+
348+
if (sortedKeys.length === 0) {
349+
return query;
350+
}
351+
352+
const commentString = sortedKeys
353+
.map(key => {
354+
const encodedValue = fixedEncodeURIComponent(headers[key]);
355+
return `${key}='${encodedValue}'`;
356+
})
357+
.join(',');
358+
359+
return `${query} /*${commentString}*/`;
360+
}

plugins/node/opentelemetry-instrumentation-pg/test/pg.test.ts

+123
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from '@opentelemetry/sdk-trace-base';
3333
import * as assert from 'assert';
3434
import type * as pg from 'pg';
35+
import * as sinon from 'sinon';
3536
import {
3637
PgInstrumentation,
3738
PgInstrumentationConfig,
@@ -44,6 +45,7 @@ import {
4445
DbSystemValues,
4546
} from '@opentelemetry/semantic-conventions';
4647
import { isSupported } from './utils';
48+
import { addSqlCommenterComment } from '../src/utils';
4749

4850
const pgVersion = require('pg/package.json').version;
4951
const nodeVersion = process.versions.node;
@@ -110,6 +112,12 @@ describe('pg', () => {
110112
const testPostgresLocally = process.env.RUN_POSTGRES_TESTS_LOCAL; // For local: spins up local postgres db via docker
111113
const shouldTest = testPostgres || testPostgresLocally; // Skips these tests if false (default)
112114

115+
function getExecutedQueries() {
116+
return (client as any).queryQueue.push.args.flat() as (pg.Query & {
117+
text?: string;
118+
})[];
119+
}
120+
113121
before(async function () {
114122
const skipForUnsupported =
115123
process.env.IN_TAV && !isSupported(nodeVersion, pgVersion);
@@ -156,11 +164,16 @@ describe('pg', () => {
156164
beforeEach(() => {
157165
contextManager = new AsyncHooksContextManager().enable();
158166
context.setGlobalContextManager(contextManager);
167+
168+
// Add a spy on the underlying client's internal query queue so that
169+
// we could assert on what the final queries are that are executed
170+
sinon.spy((client as any).queryQueue, 'push');
159171
});
160172

161173
afterEach(() => {
162174
memoryExporter.reset();
163175
context.disable();
176+
sinon.restore();
164177
});
165178

166179
it('should return an instrumentation', () => {
@@ -649,6 +662,116 @@ describe('pg', () => {
649662
});
650663
});
651664

665+
it('should not add sqlcommenter comment when flag is not specified', async () => {
666+
const span = tracer.startSpan('test span');
667+
await context.with(trace.setSpan(context.active(), span), async () => {
668+
try {
669+
const query = 'SELECT NOW()';
670+
const resPromise = await client.query(query);
671+
assert.ok(resPromise);
672+
673+
const [span] = memoryExporter.getFinishedSpans();
674+
assert.ok(span);
675+
676+
const commentedQuery = addSqlCommenterComment(
677+
trace.wrapSpanContext(span.spanContext()),
678+
query
679+
);
680+
681+
const executedQueries = getExecutedQueries();
682+
assert.equal(executedQueries.length, 1);
683+
assert.equal(executedQueries[0].text, query);
684+
assert.notEqual(query, commentedQuery);
685+
} catch (e) {
686+
assert.ok(false, e.message);
687+
}
688+
});
689+
});
690+
691+
it('should not add sqlcommenter comment with client.query({text, callback}) when flag is not specified', done => {
692+
const span = tracer.startSpan('test span');
693+
context.with(trace.setSpan(context.active(), span), () => {
694+
const query = 'SELECT NOW()';
695+
client.query({
696+
text: query,
697+
callback: (err: Error, res: pg.QueryResult) => {
698+
assert.strictEqual(err, null);
699+
assert.ok(res);
700+
701+
const [span] = memoryExporter.getFinishedSpans();
702+
const commentedQuery = addSqlCommenterComment(
703+
trace.wrapSpanContext(span.spanContext()),
704+
query
705+
);
706+
707+
const executedQueries = getExecutedQueries();
708+
assert.equal(executedQueries.length, 1);
709+
assert.equal(executedQueries[0].text, query);
710+
assert.notEqual(query, commentedQuery);
711+
done();
712+
},
713+
} as pg.QueryConfig);
714+
});
715+
});
716+
717+
it('should add sqlcommenter comment when addSqlCommenterCommentToQueries=true is specified', async () => {
718+
instrumentation.setConfig({
719+
addSqlCommenterCommentToQueries: true,
720+
});
721+
722+
const span = tracer.startSpan('test span');
723+
await context.with(trace.setSpan(context.active(), span), async () => {
724+
try {
725+
const query = 'SELECT NOW()';
726+
const resPromise = await client.query(query);
727+
assert.ok(resPromise);
728+
729+
const [span] = memoryExporter.getFinishedSpans();
730+
const commentedQuery = addSqlCommenterComment(
731+
trace.wrapSpanContext(span.spanContext()),
732+
query
733+
);
734+
735+
const executedQueries = getExecutedQueries();
736+
assert.equal(executedQueries.length, 1);
737+
assert.equal(executedQueries[0].text, commentedQuery);
738+
assert.notEqual(query, commentedQuery);
739+
} catch (e) {
740+
assert.ok(false, e.message);
741+
}
742+
});
743+
});
744+
745+
it('should add sqlcommenter comment when addSqlCommenterCommentToQueries=true is specified with client.query({text, callback})', done => {
746+
instrumentation.setConfig({
747+
addSqlCommenterCommentToQueries: true,
748+
});
749+
750+
const span = tracer.startSpan('test span');
751+
context.with(trace.setSpan(context.active(), span), () => {
752+
const query = 'SELECT NOW()';
753+
client.query({
754+
text: query,
755+
callback: (err: Error, res: pg.QueryResult) => {
756+
assert.strictEqual(err, null);
757+
assert.ok(res);
758+
759+
const [span] = memoryExporter.getFinishedSpans();
760+
const commentedQuery = addSqlCommenterComment(
761+
trace.wrapSpanContext(span.spanContext()),
762+
query
763+
);
764+
765+
const executedQueries = getExecutedQueries();
766+
assert.equal(executedQueries.length, 1);
767+
assert.equal(executedQueries[0].text, commentedQuery);
768+
assert.notEqual(query, commentedQuery);
769+
done();
770+
},
771+
} as pg.QueryConfig);
772+
});
773+
});
774+
652775
it('should not generate traces for client.query() when requireParentSpan=true is specified', done => {
653776
instrumentation.setConfig({
654777
requireParentSpan: true,

0 commit comments

Comments
 (0)