diff --git a/.circleci/config.yml b/.circleci/config.yml index 8138c5e300..bc61c37361 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,18 @@ version: 2 +test_env: &test_env + RUN_POSTGRES_TESTS: 1 + POSTGRES_USER: postgres + POSTGRES_DB: circle_database + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + +postgres_service: &postgres_service + image: circleci/postgres:9.6-alpine + environment: # env to pass to CircleCI, specified values must match test_env + POSTGRES_USER: postgres + POSTGRES_DB: circle_database + node_unit_tests: &node_unit_tests steps: - checkout @@ -71,18 +84,26 @@ jobs: node8: docker: - image: node:8 + environment: *test_env + - *postgres_service <<: *node_unit_tests node10: docker: - image: node:10 + environment: *test_env + - *postgres_service <<: *node_unit_tests node11: docker: - image: node:11 + environment: *test_env + - *postgres_service <<: *node_unit_tests node12: docker: - image: node:12 + environment: *test_env + - *postgres_service <<: *node_unit_tests node12-browsers: docker: diff --git a/packages/opentelemetry-plugin-postgres/README.md b/packages/opentelemetry-plugin-postgres/README.md index db2d211a1d..f18233a45e 100644 --- a/packages/opentelemetry-plugin-postgres/README.md +++ b/packages/opentelemetry-plugin-postgres/README.md @@ -23,6 +23,10 @@ const opentelemetry = require('@opentelemetry/plugin-postgres'); // TODO: DEMONSTRATE API ``` +## Supported Versions + +- [pg](https://npmjs.com/package/pg): `7.x` + ## Useful links - For more information on OpenTelemetry, visit: - For more about OpenTelemetry JavaScript: diff --git a/packages/opentelemetry-plugin-postgres/package.json b/packages/opentelemetry-plugin-postgres/package.json index 44d9402c53..1a785a4f7f 100644 --- a/packages/opentelemetry-plugin-postgres/package.json +++ b/packages/opentelemetry-plugin-postgres/package.json @@ -7,9 +7,12 @@ "types": "build/src/index.d.ts", "repository": "open-telemetry/opentelemetry-js", "scripts": { - "test": "nyc ts-mocha -p tsconfig.json 'test/**/*.ts'", + "test": "nyc ts-mocha -p tsconfig.json 'test/**/*.test.ts'", + "test:debug": "ts-mocha --inspect-brk --no-timeouts -p tsconfig.json 'test/**/*.test.ts'", + "test:local": "cross-env RUN_POSTGRES_TESTS_LOCAL=true yarn test", "tdd": "yarn test -- --watch-extensions ts --watch", "clean": "rimraf build/*", + "codecov": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../", "check": "gts check", "compile": "tsc -p .", "fix": "gts fix", @@ -43,11 +46,14 @@ "devDependencies": { "@types/mocha": "^5.2.7", "@types/node": "^12.6.9", + "@types/pg": "^7.11.2", + "@types/shimmer": "^1.0.1", "codecov": "^3.5.0", - "gts": "^1.1.0", + "gts": "^1.0.0", "mocha": "^6.2.0", "nyc": "^14.1.1", "rimraf": "^3.0.0", + "pg": "^7.12.1", "tslint-microsoft-contrib": "^6.2.0", "tslint-consistent-codestyle": "^1.15.1", "ts-mocha": "^6.0.0", @@ -57,6 +63,8 @@ "dependencies": { "@opentelemetry/core": "^0.1.1", "@opentelemetry/node": "^0.1.1", - "@opentelemetry/types": "^0.1.1" + "@opentelemetry/tracing": "^0.1.1", + "@opentelemetry/types": "^0.1.1", + "shimmer": "^1.2.1" } } diff --git a/packages/opentelemetry-plugin-postgres/src/enums.ts b/packages/opentelemetry-plugin-postgres/src/enums.ts new file mode 100644 index 0000000000..2df81cef3f --- /dev/null +++ b/packages/opentelemetry-plugin-postgres/src/enums.ts @@ -0,0 +1,36 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export enum AttributeNames { + // required by https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-semantic-conventions.md#databases-client-calls + COMPONENT = 'component', + DB_TYPE = 'db.type', + DB_INSTANCE = 'db.instance', + DB_STATEMENT = 'db.statement', + PEER_ADDRESS = 'peer.address', + PEER_HOSTNAME = 'peer.host', + + // optional + DB_USER = 'db.user', + PEER_PORT = 'peer.port', + PEER_IPV4 = 'peer.ipv4', + PEER_IPV6 = 'peer.ipv6', + PEER_SERVICE = 'peer.service', + + // PG specific -- not specified by spec + PG_VALUES = 'pg.values', + PG_PLAN = 'pg.plan', +} diff --git a/packages/opentelemetry-plugin-postgres/src/index.ts b/packages/opentelemetry-plugin-postgres/src/index.ts index ae225f6b52..33dab806f8 100644 --- a/packages/opentelemetry-plugin-postgres/src/index.ts +++ b/packages/opentelemetry-plugin-postgres/src/index.ts @@ -13,3 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './pg'; diff --git a/packages/opentelemetry-plugin-postgres/src/pg.ts b/packages/opentelemetry-plugin-postgres/src/pg.ts new file mode 100644 index 0000000000..e41ceee08a --- /dev/null +++ b/packages/opentelemetry-plugin-postgres/src/pg.ts @@ -0,0 +1,161 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BasePlugin } from '@opentelemetry/core'; +import { CanonicalCode, Span } from '@opentelemetry/types'; +import { + PostgresPluginOptions, + PgClientExtended, + PgPluginQueryConfig, + PostgresCallback, +} from './types'; +import * as pgTypes from 'pg'; +import * as shimmer from 'shimmer'; +import * as utils from './utils'; + +export class PostgresPlugin extends BasePlugin { + protected _config: PostgresPluginOptions; + + static readonly COMPONENT = 'pg'; + static readonly DB_TYPE = 'sql'; + + static readonly BASE_SPAN_NAME = PostgresPlugin.COMPONENT + '.query'; + + readonly supportedVersions = ['7.*']; + + constructor(readonly moduleName: string) { + super(); + this._config = {}; + } + + protected patch(): typeof pgTypes { + if (this._moduleExports.Client.prototype.query) { + shimmer.wrap( + this._moduleExports.Client.prototype, + 'query', + this._getClientQueryPatch() as never + ); + } + return this._moduleExports; + } + + protected unpatch(): void { + if (this._moduleExports.Client.prototype.query) { + shimmer.unwrap(this._moduleExports.Client.prototype, 'query'); + } + } + + private _getClientQueryPatch() { + const plugin = this; + return (original: typeof pgTypes.Client.prototype.query) => { + plugin._logger.debug( + `Patching ${PostgresPlugin.COMPONENT}.Client.prototype.query` + ); + return function query( + this: pgTypes.Client & PgClientExtended, + ...args: unknown[] + ) { + let span: Span; + + // Handle different client.query(...) signatures + if (typeof args[0] === 'string') { + if (args.length > 1 && args[1] instanceof Array) { + span = utils.handleParameterizedQuery.call( + this, + plugin._tracer, + ...args + ); + } else { + span = utils.handleTextQuery.call(this, plugin._tracer, ...args); + } + } else if (typeof args[0] === 'object') { + span = utils.handleConfigQuery.call(this, plugin._tracer, ...args); + } else { + return utils.handleInvalidQuery.call( + this, + plugin._tracer, + original, + ...args + ); + } + + // Bind callback to parent span + if (args.length > 0) { + const parentSpan = plugin._tracer.getCurrentSpan(); + if (typeof args[args.length - 1] === 'function') { + // Patch ParameterQuery callback + args[args.length - 1] = utils.patchCallback(span, args[ + args.length - 1 + ] as PostgresCallback); + // If a parent span exists, bind the callback + if (parentSpan) { + args[args.length - 1] = plugin._tracer.bind( + args[args.length - 1] + ); + } + } else if ( + typeof (args[0] as PgPluginQueryConfig).callback === 'function' + ) { + // Patch ConfigQuery callback + let callback = utils.patchCallback( + span, + (args[0] as PgPluginQueryConfig).callback! + ); + // If a parent span existed, bind the callback + if (parentSpan) { + callback = plugin._tracer.bind(callback); + } + + // Copy the callback instead of writing to args.callback so that we don't modify user's + // original callback reference + args[0] = { ...(args[0] as object), callback }; + } + } + + // Perform the original query + const result: unknown = original.apply(this, args as never); + + // Bind promise to parent span and end the span + if (result instanceof Promise) { + return result + .then((result: unknown) => { + // Return a pass-along promise which ends the span and then goes to user's orig resolvers + return new Promise((resolve, _) => { + span.setStatus({ code: CanonicalCode.OK }); + span.end(); + resolve(result); + }); + }) + .catch((error: Error) => { + return new Promise((_, reject) => { + span.setStatus({ + code: CanonicalCode.UNKNOWN, + message: error.message, + }); + span.end(); + reject(error); + }); + }); + } + + // else returns void + return result; // void + }; + }; + } +} + +export const plugin = new PostgresPlugin(PostgresPlugin.COMPONENT); diff --git a/packages/opentelemetry-plugin-postgres/src/types.ts b/packages/opentelemetry-plugin-postgres/src/types.ts new file mode 100644 index 0000000000..c9d1f70974 --- /dev/null +++ b/packages/opentelemetry-plugin-postgres/src/types.ts @@ -0,0 +1,38 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as pgTypes from 'pg'; + +export interface PostgresPluginOptions {} + +export type PostgresCallback = (err: Error, res: object) => unknown; + +// These are not included in @types/pg, so manually define them. +// https://github.com/brianc/node-postgres/blob/fde5ec586e49258dfc4a2fcd861fcdecb4794fc3/lib/client.js#L25 +export interface PgClientConnectionParams { + database: string; + host: string; + port: number; + user: string; +} + +export interface PgClientExtended { + connectionParameters: PgClientConnectionParams; +} + +export interface PgPluginQueryConfig extends pgTypes.QueryConfig { + callback?: PostgresCallback; +} diff --git a/packages/opentelemetry-plugin-postgres/src/utils.ts b/packages/opentelemetry-plugin-postgres/src/utils.ts new file mode 100644 index 0000000000..5e74204b3b --- /dev/null +++ b/packages/opentelemetry-plugin-postgres/src/utils.ts @@ -0,0 +1,181 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Span, CanonicalCode, Tracer, SpanKind } from '@opentelemetry/types'; +import { AttributeNames } from './enums'; +import { + PgClientExtended, + PgPluginQueryConfig, + PostgresCallback, + PgClientConnectionParams, +} from './types'; +import * as pgTypes from 'pg'; +import { PostgresPlugin } from './pg'; + +function arrayStringifyHelper(arr: Array): string { + return '[' + arr.toString() + ']'; +} + +// Helper function to get a low cardinality command name from the full text query +function getCommandFromText(text?: string): string { + if (!text) return 'unknown'; + const words = text.split(' '); + return words[0].length > 0 ? words[0] : 'unknown'; +} + +function getJDBCString(params: PgClientConnectionParams) { + const host = params.host || 'localhost'; // postgres defaults to localhost + const port = params.port || 5432; // postgres defaults to port 5432 + const database = params.database || ''; + return `jdbc:postgresql://${host}:${port}/${database}`; +} + +// Private helper function to start a span +function pgStartSpan( + tracer: Tracer, + client: pgTypes.Client & PgClientExtended, + name: string +) { + const jdbcString = getJDBCString(client.connectionParameters); + return tracer.startSpan(name, { + kind: SpanKind.CLIENT, + parent: tracer.getCurrentSpan() || undefined, + attributes: { + [AttributeNames.COMPONENT]: PostgresPlugin.COMPONENT, // required + [AttributeNames.DB_INSTANCE]: client.connectionParameters.database, // required + [AttributeNames.DB_TYPE]: PostgresPlugin.DB_TYPE, // required + [AttributeNames.PEER_ADDRESS]: jdbcString, // required + [AttributeNames.PEER_HOSTNAME]: client.connectionParameters.host, // required + [AttributeNames.PEER_PORT]: client.connectionParameters.port, + [AttributeNames.DB_USER]: client.connectionParameters.user, + }, + }); +} + +// Queries where args[0] is a QueryConfig +export function handleConfigQuery( + this: pgTypes.Client & PgClientExtended, + tracer: Tracer, + ...args: unknown[] +) { + const argsConfig = args[0] as PgPluginQueryConfig; + + // Set child span name + const queryCommand = getCommandFromText(argsConfig.name || argsConfig.text); + const name = PostgresPlugin.BASE_SPAN_NAME + ':' + queryCommand; + const span = pgStartSpan(tracer, this, name); + + // Set attributes + if (argsConfig.text) { + span.setAttribute(AttributeNames.DB_STATEMENT, argsConfig.text); + } + + if (argsConfig.values instanceof Array) { + span.setAttribute( + AttributeNames.PG_VALUES, + arrayStringifyHelper(argsConfig.values) + ); + } + // Set plan name attribute, if present + if (argsConfig.name) { + span.setAttribute(AttributeNames.PG_PLAN, argsConfig.name); + } + + return span; +} + +// Queries where args[1] is a 'values' array +export function handleParameterizedQuery( + this: pgTypes.Client & PgClientExtended, + tracer: Tracer, + ...args: unknown[] +) { + // Set child span name + const queryCommand = getCommandFromText(args[0] as string); + const name = PostgresPlugin.BASE_SPAN_NAME + ':' + queryCommand; + const span = pgStartSpan(tracer, this, name); + + // Set attributes + span.setAttribute(AttributeNames.DB_STATEMENT, args[0]); + if (args[1] instanceof Array) { + span.setAttribute(AttributeNames.PG_VALUES, arrayStringifyHelper(args[1])); + } + + return span; +} + +// Queries where args[0] is a text query and 'values' was not specified +export function handleTextQuery( + this: pgTypes.Client & PgClientExtended, + tracer: Tracer, + ...args: unknown[] +) { + // Set child span name + const queryCommand = getCommandFromText(args[0] as string); + const name = PostgresPlugin.BASE_SPAN_NAME + ':' + queryCommand; + const span = pgStartSpan(tracer, this, name); + + // Set attributes + span.setAttribute(AttributeNames.DB_STATEMENT, args[0]); + + return span; +} + +/** + * Invalid query handler. We should never enter this function unless invalid args were passed to the driver. + * Create and immediately end a new span + */ +export function handleInvalidQuery( + this: pgTypes.Client & PgClientExtended, + tracer: Tracer, + originalQuery: typeof pgTypes.Client.prototype.query, + ...args: unknown[] +) { + let result; + const span = pgStartSpan(tracer, this, PostgresPlugin.BASE_SPAN_NAME); + try { + result = originalQuery.apply(this, args as never); + span.setStatus({ code: CanonicalCode.OK }); // this will never happen, but set a status anyways + } catch (e) { + span.setStatus({ code: CanonicalCode.UNKNOWN, message: e.message }); + throw e; + } finally { + span.end(); + } + return result; +} + +export function patchCallback( + span: Span, + cb: PostgresCallback +): PostgresCallback { + return function patchedCallback( + this: pgTypes.Client & PgClientExtended, + err: Error, + res: object + ) { + if (err) { + span.setStatus({ + code: CanonicalCode.UNKNOWN, + message: err.message, + }); + } else if (res) { + span.setStatus({ code: CanonicalCode.OK }); + } + span.end(); + cb.call(this, err, res); + }; +} diff --git a/packages/opentelemetry-plugin-postgres/test/assertionUtils.ts b/packages/opentelemetry-plugin-postgres/test/assertionUtils.ts new file mode 100644 index 0000000000..2c81918305 --- /dev/null +++ b/packages/opentelemetry-plugin-postgres/test/assertionUtils.ts @@ -0,0 +1,82 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + SpanKind, + Attributes, + Event, + Span, + Status, +} from '@opentelemetry/types'; +import * as assert from 'assert'; +import { PostgresPlugin } from '../src'; +import { ReadableSpan } from '@opentelemetry/tracing'; +import { + hrTimeToMilliseconds, + hrTimeToMicroseconds, +} from '@opentelemetry/core'; +import { AttributeNames } from '../src/enums'; + +export const assertSpan = ( + span: ReadableSpan, + kind: SpanKind, + attributes: Attributes, + events: Event[], + status: Status +) => { + assert.strictEqual(span.spanContext.traceId.length, 32); + assert.strictEqual(span.spanContext.spanId.length, 16); + assert.strictEqual(span.kind, kind); + + assert.strictEqual( + span.attributes[AttributeNames.COMPONENT], + PostgresPlugin.COMPONENT + ); + assert.ok(span.endTime); + assert.strictEqual(span.links.length, 0); + + assert.ok( + hrTimeToMicroseconds(span.startTime) < hrTimeToMicroseconds(span.endTime) + ); + assert.ok(hrTimeToMilliseconds(span.endTime) > 0); + + // attributes + assert.deepStrictEqual(span.attributes, attributes); + + // events + assert.deepStrictEqual(span.events, events); + + assert.strictEqual(span.status.code, status.code); + if (status.message) { + assert.strictEqual(span.status.message, status.message); + } +}; + +// Check if sourceSpan was propagated to targetSpan +export const assertPropagation = ( + childSpan: ReadableSpan, + parentSpan: Span +) => { + const targetSpanContext = childSpan.spanContext; + const sourceSpanContext = parentSpan.context(); + assert.strictEqual(targetSpanContext.traceId, sourceSpanContext.traceId); + assert.strictEqual(childSpan.parentSpanId, sourceSpanContext.spanId); + assert.strictEqual( + targetSpanContext.traceFlags, + sourceSpanContext.traceFlags + ); + assert.notStrictEqual(targetSpanContext.spanId, sourceSpanContext.spanId); +}; diff --git a/packages/opentelemetry-plugin-postgres/test/pg.test.ts b/packages/opentelemetry-plugin-postgres/test/pg.test.ts new file mode 100644 index 0000000000..d30ad3a325 --- /dev/null +++ b/packages/opentelemetry-plugin-postgres/test/pg.test.ts @@ -0,0 +1,435 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NoopLogger } from '@opentelemetry/core'; +import { NodeTracer } from '@opentelemetry/node'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/tracing'; +import { + SpanKind, + Attributes, + TimedEvent, + Span, + CanonicalCode, + Status, +} from '@opentelemetry/types'; +import { plugin, PostgresPlugin } from '../src'; +import { AttributeNames } from '../src/enums'; +import * as assert from 'assert'; +import * as pg from 'pg'; +import * as assertionUtils from './assertionUtils'; +import * as testUtils from './testUtils'; + +const memoryExporter = new InMemorySpanExporter(); + +const CONFIG = { + user: process.env.POSTGRES_USER || 'postgres', + database: process.env.POSTGRES_DB || 'postgres', + host: process.env.POSTGRES_HOST || 'localhost', + port: process.env.POSTGRES_PORT + ? parseInt(process.env.POSTGRES_PORT, 10) + : 54320, +}; + +const DEFAULT_ATTRIBUTES = { + [AttributeNames.COMPONENT]: PostgresPlugin.COMPONENT, + [AttributeNames.DB_INSTANCE]: CONFIG.database, + [AttributeNames.DB_TYPE]: PostgresPlugin.DB_TYPE, + [AttributeNames.PEER_HOSTNAME]: CONFIG.host, + [AttributeNames.PEER_ADDRESS]: `jdbc:postgresql://${CONFIG.host}:${CONFIG.port}/${CONFIG.database}`, + [AttributeNames.PEER_PORT]: CONFIG.port, + [AttributeNames.DB_USER]: CONFIG.user, +}; + +const okStatus: Status = { + code: CanonicalCode.OK, +}; +const unknownStatus: Status = { + code: CanonicalCode.UNKNOWN, +}; + +const runCallbackTest = ( + span: Span | null, + attributes: Attributes, + events: TimedEvent[], + status: Status = okStatus, + spansLength = 1, + spansIndex = 0 +) => { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, spansLength); + const pgSpan = spans[spansIndex]; + assertionUtils.assertSpan( + pgSpan, + SpanKind.CLIENT, + attributes, + events, + status + ); + if (span) { + assertionUtils.assertPropagation(pgSpan, span); + } +}; + +describe('pg@7.x', () => { + let client: pg.Client; + const tracer = new NodeTracer(); + const logger = new NoopLogger(); + const testPostgres = process.env.RUN_POSTGRES_TESTS; // For CI: assumes local postgres db is already available + const testPostgresLocally = process.env.RUN_POSTGRES_TESTS_LOCAL; // For local: spins up local postgres db via docker + const shouldTest = testPostgres || testPostgresLocally; // Skips these tests if false (default) + + before(async function() { + if (!shouldTest) { + // this.skip() workaround + // https://github.com/mochajs/mocha/issues/2683#issuecomment-375629901 + this.test!.parent!.pending = true; + this.skip(); + } + tracer.addSpanProcessor(new SimpleSpanProcessor(memoryExporter)); + if (testPostgresLocally) { + testUtils.startDocker(); + } + + client = new pg.Client(CONFIG); + try { + await client.connect(); + } catch (e) { + throw e; + } + }); + + after(async () => { + if (testPostgresLocally) { + testUtils.cleanUpDocker(); + } + await client.end(); + }); + + beforeEach(function() { + plugin.enable(pg, tracer, logger); + }); + + afterEach(() => { + memoryExporter.reset(); + plugin.disable(); + }); + + it('should return a plugin', () => { + assert.ok(plugin instanceof PostgresPlugin); + }); + + it('should have correct moduleName', () => { + assert.strictEqual(plugin.moduleName, 'pg'); + }); + + it('should maintain pg module error throwing behavior with bad arguments', () => { + const assertPgError = (e: Error) => { + const src = e.stack!.split('\n').map(line => line.trim())[1]; + return /node_modules[/\\]pg/.test(src); + }; + + assert.throws( + () => { + (client as any).query(); + }, + assertPgError, + 'pg should throw when no args provided' + ); + runCallbackTest(null, DEFAULT_ATTRIBUTES, [], unknownStatus); + memoryExporter.reset(); + + assert.doesNotThrow( + () => + (client as any).query({ foo: 'bar' }, undefined, () => { + runCallbackTest( + null, + { + ...DEFAULT_ATTRIBUTES, + }, + [], + unknownStatus + ); + }), + 'pg should not throw when invalid config args are provided' + ); + }); + + describe('#client.query(...)', () => { + it('should not return a promise if callback is provided', done => { + const res = client.query('SELECT NOW()', (err, res) => { + assert.strictEqual(err, null); + done(); + }); + assert.strictEqual(res, undefined, 'No promise is returned'); + }); + + it('should return a promise if callback is provided', done => { + const resPromise = client.query('SELECT NOW()'); + resPromise + .then(res => { + assert.ok(res); + done(); + }) + .catch((err: Error) => { + assert.ok(false, err.message); + }); + }); + + it('should intercept client.query(text, callback)', done => { + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.DB_STATEMENT]: 'SELECT NOW()', + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + tracer.withSpan(span, () => { + const res = client.query('SELECT NOW()', (err, res) => { + assert.strictEqual(err, null); + assert.ok(res); + runCallbackTest(span, attributes, events); + done(); + }); + assert.strictEqual(res, undefined, 'No promise is returned'); + }); + }); + + it('should intercept client.query(text, values, callback)', done => { + const query = 'SELECT $1::text'; + const values = ['0']; + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.DB_STATEMENT]: query, + [AttributeNames.PG_VALUES]: '[0]', + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + tracer.withSpan(span, () => { + const resNoPromise = client.query(query, values, (err, res) => { + assert.strictEqual(err, null); + assert.ok(res); + runCallbackTest(span, attributes, events); + done(); + }); + assert.strictEqual(resNoPromise, undefined, 'No promise is returned'); + }); + }); + + it('should intercept client.query({text, callback})', done => { + const query = 'SELECT NOW()'; + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.DB_STATEMENT]: query, + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + tracer.withSpan(span, () => { + const resNoPromise = client.query({ + text: query, + callback: (err: Error, res: pg.QueryResult) => { + assert.strictEqual(err, null); + assert.ok(res); + runCallbackTest(span, attributes, events); + done(); + }, + } as pg.QueryConfig); + assert.strictEqual(resNoPromise, undefined, 'No promise is returned'); + }); + }); + + it('should intercept client.query({text}, callback)', done => { + const query = 'SELECT NOW()'; + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.DB_STATEMENT]: query, + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + tracer.withSpan(span, () => { + const resNoPromise = client.query({ text: query }, (err, res) => { + assert.strictEqual(err, null); + assert.ok(res); + runCallbackTest(span, attributes, events); + done(); + }); + assert.strictEqual(resNoPromise, undefined, 'No promise is returned'); + }); + }); + + it('should intercept client.query(text, values)', async () => { + const query = 'SELECT $1::text'; + const values = ['0']; + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.DB_STATEMENT]: query, + [AttributeNames.PG_VALUES]: '[0]', + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + await tracer.withSpan(span, async () => { + const resPromise = await client.query(query, values); + try { + assert.ok(resPromise); + runCallbackTest(span, attributes, events); + } catch (e) { + assert.ok(false, e.message); + } + }); + }); + + it('should intercept client.query({text, values})', async () => { + const query = 'SELECT $1::text'; + const values = ['0']; + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.DB_STATEMENT]: query, + [AttributeNames.PG_VALUES]: '[0]', + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + await tracer.withSpan(span, async () => { + const resPromise = await client.query({ + text: query, + values: values, + }); + try { + assert.ok(resPromise); + runCallbackTest(span, attributes, events); + } catch (e) { + assert.ok(false, e.message); + } + }); + }); + + it('should intercept client.query(plan)', async () => { + const name = 'fetch-text'; + const query = 'SELECT $1::text'; + const values = ['0']; + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.PG_PLAN]: name, + [AttributeNames.DB_STATEMENT]: query, + [AttributeNames.PG_VALUES]: '[0]', + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + + await tracer.withSpan(span, async () => { + try { + const resPromise = await client.query({ + name: name, + text: query, + values: values, + }); + assert.strictEqual(resPromise.command, 'SELECT'); + runCallbackTest(span, attributes, events); + } catch (e) { + assert.ok(false, e.message); + } + }); + }); + + it('should intercept client.query(text)', async () => { + const query = 'SELECT NOW()'; + const attributes = { + ...DEFAULT_ATTRIBUTES, + [AttributeNames.DB_STATEMENT]: query, + }; + const events: TimedEvent[] = []; + const span = tracer.startSpan('test span'); + await tracer.withSpan(span, async () => { + try { + const resPromise = await client.query(query); + assert.ok(resPromise); + runCallbackTest(span, attributes, events); + } catch (e) { + assert.ok(false, e.message); + } + }); + }); + + it('should handle the same callback being given to multiple client.query()s', done => { + let events = 0; + + const queryHandler = (err: Error, res: pg.QueryResult) => { + const span = tracer.getCurrentSpan(); + assert.ok(span); + assert.strictEqual((span as any)['_ended'], false); + if (err) { + throw err; + } + events += 1; + }; + + const config = { + text: 'SELECT NOW()', + callback: queryHandler, + }; + + client.query(config.text, config.callback); // 1 + client.query(config); // 2 + client.query(config.text, queryHandler); // 3 + client.query(config.text, queryHandler); // 4 + client.query(config.text); // Not using queryHandler + client.query(config); // 5 + client.query(config); // 6 + client.query(config.text, (err, res) => { + assert.strictEqual(events, 6); + done(); + }); + }); + + it('should preserve correct context even when using the same callback in client.query()', done => { + const spans = [tracer.startSpan('span 1'), tracer.startSpan('span 2')]; + const currentSpans: (Span | null)[] = []; + const queryHandler = () => { + currentSpans.push(tracer.getCurrentSpan()); + if (currentSpans.length === 2) { + assert.deepStrictEqual(currentSpans, spans); + done(); + } + }; + + tracer.withSpan(spans[0], () => { + client.query('SELECT NOW()', queryHandler); + }); + tracer.withSpan(spans[1], () => { + client.query('SELECT NOW()', queryHandler); + }); + }); + + it('should preserve correct context even when using the same promise resolver in client.query()', done => { + const spans = [tracer.startSpan('span 1'), tracer.startSpan('span 2')]; + const currentSpans: (Span | null)[] = []; + const queryHandler = () => { + currentSpans.push(tracer.getCurrentSpan()); + if (currentSpans.length === 2) { + assert.deepStrictEqual(currentSpans, spans); + done(); + } + }; + + tracer.withSpan(spans[0], () => { + client.query('SELECT NOW()').then(queryHandler); + }); + tracer.withSpan(spans[1], () => { + client.query('SELECT NOW()').then(queryHandler); + }); + }); + }); +}); diff --git a/packages/opentelemetry-plugin-postgres/test/testUtils.ts b/packages/opentelemetry-plugin-postgres/test/testUtils.ts new file mode 100644 index 0000000000..eec866fc41 --- /dev/null +++ b/packages/opentelemetry-plugin-postgres/test/testUtils.ts @@ -0,0 +1,54 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as childProcess from 'child_process'; +export function startDocker() { + const tasks = [ + run('docker run -d -p 54320:5432 --name otpostgres postgres:alpine'), + ]; + + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + if (task && task.code !== 0) { + console.error('Failed to start container!'); + console.error(task.output); + return false; + } + } + return true; +} + +export function cleanUpDocker() { + run('docker stop otpostgres'); + run('docker rm otpostgres'); +} + +function run(cmd: string) { + try { + const proc = childProcess.spawnSync(cmd, { + shell: true, + }); + return { + code: proc.status, + output: proc.output + .map(v => String.fromCharCode.apply(null, v as any)) + .join(''), + }; + } catch (e) { + console.log(e); + return; + } +}