From 9326c99f3cdf3e0166f74093a8093066be78bd0a Mon Sep 17 00:00:00 2001 From: Rauno Viskus Date: Fri, 14 Jan 2022 15:15:05 +0200 Subject: [PATCH] feat: implement instrumentation for `tedious` (#799) * feat: implement instrumentation for tedious * feat: collect procedure and statement counts + clean up after tests * test: spin up MSSQL DB for the tests * test: add mssql service to GHA * fix: reference the correct vars * fix: add nyc to test command * fix: use older version of mocha * fix: ignore tedious on node@8 - it's not supported * feat: remove listeners when the span ends * feat: track database changes * feat: support <11.0.9 * feat: support <11.0.8 * feat: support <8.3.0 * feat: support <7 * feat: support >=1.10.0 <4 * style: lint and types * chore: remove the "opentelemetry-" prefix * refactor: namespace api functionality * feat: instrument bulk loads * feat: support bulkLoad on old versions * docs: fix the supported versions range * fix: rename --include-filtered-dependencies * fix: use localhost for the mssql service * fix: map the port properly * chore: add me as the component owner * docs: add comments about cardinality in `getSpanName` * fix: short-circuit in the case of unexpected input * feat: give current-database symbol more descriptive name * fix: fix types for arguments * fix: ensure setDatabase listener is only ever added once * docs: add tedious to the main README * style: fix lint * feat: log an error if request.callback is not a function * docs: touch up the package.json --- .github/component_owners.yml | 2 + .github/workflows/unit-test.yml | 16 + README.md | 10 +- .../src/test-utils.ts | 14 +- .../instrumentation-tedious/.eslintignore | 1 + .../node/instrumentation-tedious/.eslintrc.js | 7 + .../node/instrumentation-tedious/.npmignore | 4 + plugins/node/instrumentation-tedious/.tav.yml | 7 + plugins/node/instrumentation-tedious/LICENSE | 201 +++++++++ .../node/instrumentation-tedious/README.md | 58 +++ .../node/instrumentation-tedious/package.json | 72 ++++ .../node/instrumentation-tedious/src/index.ts | 22 + .../src/instrumentation.ts | 240 +++++++++++ .../node/instrumentation-tedious/src/types.ts | 19 + .../node/instrumentation-tedious/src/utils.ts | 53 +++ .../node/instrumentation-tedious/test/api.ts | 333 +++++++++++++++ .../test/instrumentation.test.ts | 393 ++++++++++++++++++ .../instrumentation-tedious/tsconfig.json | 11 + 18 files changed, 1455 insertions(+), 8 deletions(-) create mode 100644 plugins/node/instrumentation-tedious/.eslintignore create mode 100644 plugins/node/instrumentation-tedious/.eslintrc.js create mode 100644 plugins/node/instrumentation-tedious/.npmignore create mode 100644 plugins/node/instrumentation-tedious/.tav.yml create mode 100644 plugins/node/instrumentation-tedious/LICENSE create mode 100644 plugins/node/instrumentation-tedious/README.md create mode 100644 plugins/node/instrumentation-tedious/package.json create mode 100644 plugins/node/instrumentation-tedious/src/index.ts create mode 100644 plugins/node/instrumentation-tedious/src/instrumentation.ts create mode 100644 plugins/node/instrumentation-tedious/src/types.ts create mode 100644 plugins/node/instrumentation-tedious/src/utils.ts create mode 100644 plugins/node/instrumentation-tedious/test/api.ts create mode 100644 plugins/node/instrumentation-tedious/test/instrumentation.test.ts create mode 100644 plugins/node/instrumentation-tedious/tsconfig.json diff --git a/.github/component_owners.yml b/.github/component_owners.yml index e9d657e9d8..155822db67 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -14,6 +14,8 @@ components: packages/opentelemetry-id-generator-aws-xray: - NathanielRN - willarmiros + plugins/node/instrumentation-tedious: + - rauno56 plugins/node/opentelemetry-instrumentation-aws-lambda: - NathanielRN - willarmiros diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 1c58b9287f..95b8d6e82b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -15,6 +15,7 @@ jobs: lerna-extra-args: >- --ignore @opentelemetry/instrumentation-aws-sdk --ignore @opentelemetry/instrumentation-pino + --ignore @opentelemetry/instrumentation-tedious - node: "10" lerna-extra-args: >- --ignore @opentelemetry/instrumentation-pino @@ -28,6 +29,19 @@ jobs: image: mongo ports: - 27017:27017 + mssql: + image: mcr.microsoft.com/mssql/server:2017-latest + env: + SA_PASSWORD: mssql_passw0rd + ACCEPT_EULA: Y + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools/bin/sqlcmd -U sa -P $SA_PASSWORD -Q 'select 1' -b -o /dev/null" + --health-interval 1s + --health-timeout 30s + --health-start-period 10s + --health-retries 20 mysql: image: circleci/mysql:5.7 env: @@ -72,12 +86,14 @@ jobs: RUN_MEMCACHED_TESTS: 1 RUN_MONGODB_TESTS: 1 RUN_MYSQL_TESTS: 1 + RUN_MSSQL_TESTS: 1 RUN_POSTGRES_TESTS: 1 RUN_REDIS_TESTS: 1 CASSANDRA_HOST: localhost MONGODB_DB: opentelemetry-tests MONGODB_HOST: localhost MONGODB_PORT: 27017 + MSSQL_PASSWORD: mssql_passw0rd MYSQL_DATABASE: circle_database MYSQL_HOST: localhost MYSQL_PASSWORD: secret diff --git a/README.md b/README.md index 93ec004e0d..a4b7b896d4 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ OpenTelemetry can collect tracing data automatically using instrumentations. Ven - [@opentelemetry/instrumentation-koa][otel-contrib-instrumentation-koa] - [@opentelemetry/instrumentation-memcached][otel-contrib-instrumentation-memcached] - [@opentelemetry/instrumentation-mongodb][otel-contrib-instrumentation-mongodb] -- [@opentelemetry/instrumentation-mysql][otel-contrib-instrumentation-mysql] - [@opentelemetry/instrumentation-mysql2][otel-contrib-instrumentation-mysql2] +- [@opentelemetry/instrumentation-mysql][otel-contrib-instrumentation-mysql] - [@opentelemetry/instrumentation-nestjs-core][otel-contrib-instrumentation-nestjs-core] - [@opentelemetry/instrumentation-net][otel-contrib-instrumentation-net] - [@opentelemetry/instrumentation-pg][otel-contrib-instrumentation-pg] @@ -77,6 +77,7 @@ OpenTelemetry can collect tracing data automatically using instrumentations. Ven - [@opentelemetry/instrumentation-redis][otel-contrib-instrumentation-redis] - [@opentelemetry/instrumentation-restify][otel-contrib-instrumentation-restify] - [@opentelemetry/instrumentation-router][otel-contrib-instrumentation-router] +- [@opentelemetry/instrumentation-tedious][otel-contrib-instrumentation-tedious] - [@opentelemetry/instrumentation-winston][otel-contrib-instrumentation-winston] ### Web Instrumentations @@ -167,10 +168,11 @@ Apache 2.0 - See [LICENSE][license-url] for more information. [otel-contrib-instrumentation-ioredis]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-ioredis [otel-contrib-instrumentation-knex]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-knex [otel-contrib-instrumentation-koa]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-koa +[otel-contrib-instrumentation-long-task]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-long-task [otel-contrib-instrumentation-memcached]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-memcached [otel-contrib-instrumentation-mongodb]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-mongodb -[otel-contrib-instrumentation-mysql]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-mysql [otel-contrib-instrumentation-mysql2]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-mysql2 +[otel-contrib-instrumentation-mysql]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-mysql [otel-contrib-instrumentation-nestjs-core]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-nestjs-core [otel-contrib-instrumentation-net]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-net [otel-contrib-instrumentation-pg]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-pg @@ -178,9 +180,9 @@ Apache 2.0 - See [LICENSE][license-url] for more information. [otel-contrib-instrumentation-redis]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-redis [otel-contrib-instrumentation-restify]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-restify [otel-contrib-instrumentation-router]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-router -[otel-contrib-instrumentation-winston]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-winston -[otel-contrib-instrumentation-long-task]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-long-task +[otel-contrib-instrumentation-tedious]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/instrumentation-tedious [otel-contrib-instrumentation-user-interaction]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-user-interaction +[otel-contrib-instrumentation-winston]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-winston [otel-contrib-plugin-react-load]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-plugin-react-load [otel-contrib-auto-instr-node]: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/metapackages/auto-instrumentations-node diff --git a/packages/opentelemetry-test-utils/src/test-utils.ts b/packages/opentelemetry-test-utils/src/test-utils.ts index e72d66fcf6..de3338b974 100644 --- a/packages/opentelemetry-test-utils/src/test-utils.ts +++ b/packages/opentelemetry-test-utils/src/test-utils.ts @@ -32,14 +32,16 @@ import * as path from 'path'; const dockerRunCmds = { cassandra: - 'docker run -d -p 9042:9042 --name otel-cassandra bitnami/cassandra:3', - redis: 'docker run --rm -d --name otel-redis -p 63790:6379 redis:alpine', + 'docker run --rm -d --name otel-cassandra -p 9042:9042 bitnami/cassandra:3', + memcached: + 'docker run --rm -d --name otel-memcached -p 11211:11211 memcached:1.6.9-alpine', + mssql: + 'docker run --rm -d --name otel-mssql -p 1433:1433 -e SA_PASSWORD=mssql_passw0rd -e ACCEPT_EULA=Y mcr.microsoft.com/mssql/server:2017-latest', mysql: 'docker run --rm -d --name otel-mysql -p 33306:3306 -e MYSQL_ROOT_PASSWORD=rootpw -e MYSQL_DATABASE=test_db -e MYSQL_USER=otel -e MYSQL_PASSWORD=secret circleci/mysql:5.7', postgres: 'docker run --rm -d --name otel-postgres -p 54320:5432 -e POSTGRES_PASSWORD=postgres postgres:13-alpine', - memcached: - 'docker run --rm -d --name otel-memcached -p 11211:11211 memcached:1.6.9-alpine', + redis: 'docker run --rm -d --name otel-redis -p 63790:6379 redis:alpine', }; export function startDocker(db: keyof typeof dockerRunCmds) { @@ -66,6 +68,10 @@ function run(cmd: string) { const proc = childProcess.spawnSync(cmd, { shell: true, }); + if (proc.status !== 0) { + console.error('Failed run command:', cmd); + console.error(proc.output); + } return { code: proc.status, output: proc.output diff --git a/plugins/node/instrumentation-tedious/.eslintignore b/plugins/node/instrumentation-tedious/.eslintignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/plugins/node/instrumentation-tedious/.eslintignore @@ -0,0 +1 @@ +build diff --git a/plugins/node/instrumentation-tedious/.eslintrc.js b/plugins/node/instrumentation-tedious/.eslintrc.js new file mode 100644 index 0000000000..f756f4488b --- /dev/null +++ b/plugins/node/instrumentation-tedious/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "env": { + "mocha": true, + "node": true + }, + ...require('../../../eslint.config.js') +} diff --git a/plugins/node/instrumentation-tedious/.npmignore b/plugins/node/instrumentation-tedious/.npmignore new file mode 100644 index 0000000000..9505ba9450 --- /dev/null +++ b/plugins/node/instrumentation-tedious/.npmignore @@ -0,0 +1,4 @@ +/bin +/coverage +/doc +/test diff --git a/plugins/node/instrumentation-tedious/.tav.yml b/plugins/node/instrumentation-tedious/.tav.yml new file mode 100644 index 0000000000..6c0737e66e --- /dev/null +++ b/plugins/node/instrumentation-tedious/.tav.yml @@ -0,0 +1,7 @@ +tedious: + # 4.0.0 is broken: https://github.com/tediousjs/tedious/commit/4eceb48 + versions: ">=1.11.0 <4 || >=4.0.1" + commands: npm run test + + # Fix missing `test-utils` package + pretest: npm run --prefix ../../../ lerna:link diff --git a/plugins/node/instrumentation-tedious/LICENSE b/plugins/node/instrumentation-tedious/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/plugins/node/instrumentation-tedious/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/plugins/node/instrumentation-tedious/README.md b/plugins/node/instrumentation-tedious/README.md new file mode 100644 index 0000000000..8c8f6e67b5 --- /dev/null +++ b/plugins/node/instrumentation-tedious/README.md @@ -0,0 +1,58 @@ +# OpenTelemetry Tedious Instrumentation for Node.js + +[![NPM Published Version][npm-img]][npm-url] +[![Apache License][license-image]][license-image] + +This module provides automatic instrumentation for [`tedious`](https://github.com/tediousjs/tedious). + +For automatic instrumentation see the +[`@opentelemetry/sdk-trace-node`](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-node) package. + +Compatible with OpenTelemetry JS API and SDK `1.0+`. + +## Installation + +```bash +npm install --save @opentelemetry/instrumentation-tedious +``` + +## Supported Versions + +- `>=1.11.0` + +## Usage + +OpenTelemetry Tedious Instrumentation allows the user to automatically collect trace data and export them to the backend of choice, to give observability to distributed systems when working with [`tedious`](https://github.com/tediousjs/tedious). + +To load a specific plugin, specify it in the registerInstrumentations's configuration: + +```js +const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); +const { TediousInstrumentation } = require('@opentelemetry/instrumentation-tedious'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); + +const provider = new NodeTracerProvider(); +provider.register(); + +registerInstrumentations({ + instrumentations: [ + new TediousInstrumentation(), + ], +}) +``` + +## Useful links + +- For more information on OpenTelemetry, visit: +- For more about OpenTelemetry JavaScript: +- For help or feedback on this project, join us in [GitHub Discussions][discussions-url] + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. + +[discussions-url]: https://github.com/open-telemetry/opentelemetry-js/discussions +[license-url]: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/LICENSE +[license-image]: https://img.shields.io/badge/license-Apache_2.0-green.svg?style=flat +[npm-url]: https://www.npmjs.com/package/@opentelemetry/instrumentation-tedious +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-tedious.svg diff --git a/plugins/node/instrumentation-tedious/package.json b/plugins/node/instrumentation-tedious/package.json new file mode 100644 index 0000000000..49c477a622 --- /dev/null +++ b/plugins/node/instrumentation-tedious/package.json @@ -0,0 +1,72 @@ +{ + "name": "@opentelemetry/instrumentation-tedious", + "version": "0.27.0", + "description": "OpenTelemetry instrumentation for `tedious`", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": "open-telemetry/opentelemetry-js-contrib", + "scripts": { + "clean": "rimraf build/*", + "codecov": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../../", + "compile": "npm run version:update && tsc -p .", + "lint:fix": "eslint . --ext .ts --fix", + "lint": "eslint . --ext .ts", + "precompile": "tsc --version && lerna run version --scope @opentelemetry/instrumentation-tedious --include-dependencies", + "prewatch": "npm run precompile", + "prepare": "npm run compile", + "tdd": "npm run test -- --watch-extensions ts --watch", + "test": "nyc ts-mocha -p tsconfig.json 'test/**/*.test.ts'", + "test-all-versions": "tav", + "version:update": "node ../../../scripts/version-update.js" + }, + "keywords": [ + "instrumentation", + "microsoft", + "mssql", + "nodejs", + "opentelemetry", + "profiling", + "sql server", + "tds", + "tedious", + "tracing" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=8.5.0" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.2" + }, + "devDependencies": { + "@opentelemetry/api": "1.0.2", + "@opentelemetry/context-async-hooks": "1.0.1", + "@opentelemetry/contrib-test-utils": "^0.28.0", + "@opentelemetry/sdk-trace-base": "1.0.1", + "@types/mocha": "7.0.2", + "@types/node": "14.17.9", + "codecov": "3.8.3", + "gts": "3.1.0", + "mocha": "^7.2.0", + "nyc": "15.1.0", + "rimraf": "3.0.2", + "tedious": "^14.0.0", + "test-all-versions": "5.0.1", + "ts-mocha": "8.0.0", + "typescript": "4.3.5" + }, + "dependencies": { + "@opentelemetry/instrumentation": "^0.27.0", + "@opentelemetry/semantic-conventions": "^1.0.0", + "@types/tedious": "^4.0.6" + } +} diff --git a/plugins/node/instrumentation-tedious/src/index.ts b/plugins/node/instrumentation-tedious/src/index.ts new file mode 100644 index 0000000000..6f2dd724b7 --- /dev/null +++ b/plugins/node/instrumentation-tedious/src/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright The 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 { TediousInstrumentation } from './instrumentation'; + +export * from './instrumentation'; +export default TediousInstrumentation; + +export { TediousInstrumentationConfig } from './types'; diff --git a/plugins/node/instrumentation-tedious/src/instrumentation.ts b/plugins/node/instrumentation-tedious/src/instrumentation.ts new file mode 100644 index 0000000000..b7f4a03c53 --- /dev/null +++ b/plugins/node/instrumentation-tedious/src/instrumentation.ts @@ -0,0 +1,240 @@ +/* + * Copyright The 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 api from '@opentelemetry/api'; +import { EventEmitter } from 'events'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + isWrapped, +} from '@opentelemetry/instrumentation'; +import { + DbSystemValues, + SemanticAttributes, +} from '@opentelemetry/semantic-conventions'; +import type * as tedious from 'tedious'; +import { TediousInstrumentationConfig } from './types'; +import { getSpanName, once } from './utils'; +import { VERSION } from './version'; + +const CURRENT_DATABASE = Symbol( + 'opentelemetry.instrumentation-tedious.current-database' +); +const PATCHED_METHODS = [ + 'callProcedure', + 'execSql', + 'execSqlBatch', + 'execBulkLoad', + 'prepare', + 'execute', +]; + +type UnknownFunction = (...args: any[]) => any; +type ApproxConnection = EventEmitter & { + [CURRENT_DATABASE]: string; + config: any; +}; +type ApproxRequest = EventEmitter & { + sqlTextOrProcedure: string | undefined; + callback: any; + table: string | undefined; + parametersByName: any; +}; + +function setDatabase(this: ApproxConnection, databaseName: string) { + Object.defineProperty(this, CURRENT_DATABASE, { + value: databaseName, + writable: true, + }); +} + +export class TediousInstrumentation extends InstrumentationBase< + typeof tedious +> { + static readonly COMPONENT = 'tedious'; + + constructor(config?: TediousInstrumentationConfig) { + super('@opentelemetry/instrumentation-tedious', VERSION, config); + } + + protected init() { + return [ + new InstrumentationNodeModuleDefinition( + TediousInstrumentation.COMPONENT, + ['*'], + (moduleExports: any, moduleVersion) => { + this._diag.debug(`Patching tedious@${moduleVersion}`); + + const ConnectionPrototype: any = moduleExports.Connection.prototype; + for (const method of PATCHED_METHODS) { + if (isWrapped(ConnectionPrototype[method])) { + this._unwrap(ConnectionPrototype, method); + } + this._wrap( + ConnectionPrototype, + method, + this._patchQuery(method) as any + ); + } + + if (isWrapped(ConnectionPrototype.connect)) { + this._unwrap(ConnectionPrototype, 'connect'); + } + this._wrap(ConnectionPrototype, 'connect', this._patchConnect); + + return moduleExports; + }, + (moduleExports: any) => { + if (moduleExports === undefined) return; + const ConnectionPrototype: any = moduleExports.Connection.prototype; + for (const method of PATCHED_METHODS) { + this._unwrap(ConnectionPrototype, method); + } + this._unwrap(ConnectionPrototype, 'connect'); + } + ), + ]; + } + + private _patchConnect(original: UnknownFunction): UnknownFunction { + return function patchedConnect(this: ApproxConnection) { + setDatabase.call(this, this.config?.options?.database); + + // remove the listener first in case it's already added + this.removeListener('databaseChange', setDatabase); + this.on('databaseChange', setDatabase); + + this.once('end', () => { + this.removeListener('databaseChange', setDatabase); + }); + return original.apply(this, arguments as unknown as any[]); + }; + } + + private _patchQuery(operation: string) { + return (originalMethod: UnknownFunction): UnknownFunction => { + const thisPlugin = this; + this._diag.debug( + `TediousInstrumentation: patched Connection.prototype.${operation}` + ); + + function patchedMethod(this: ApproxConnection, request: ApproxRequest) { + if (!(request instanceof EventEmitter)) { + thisPlugin._diag.warn( + `Unexpected invocation of patched ${operation} method. Span not recorded` + ); + return originalMethod.apply(this, arguments as unknown as any[]); + } + let procCount = 0; + let statementCount = 0; + const incrementStatementCount = () => statementCount++; + const incrementProcCount = () => procCount++; + const databaseName = this[CURRENT_DATABASE]; + const sql = (request => { + // Required for <11.0.9 + if ( + request.sqlTextOrProcedure === 'sp_prepare' && + request.parametersByName?.stmt?.value + ) { + return request.parametersByName.stmt.value; + } + return request.sqlTextOrProcedure; + })(request); + + const span = thisPlugin.tracer.startSpan( + getSpanName(operation, databaseName, sql, request.table), + { + kind: api.SpanKind.CLIENT, + attributes: { + [SemanticAttributes.DB_SYSTEM]: DbSystemValues.MSSQL, + [SemanticAttributes.DB_NAME]: databaseName, + [SemanticAttributes.NET_PEER_PORT]: this.config?.options?.port, + [SemanticAttributes.NET_PEER_NAME]: this.config?.server, + // >=4 uses `authentication` object, older versions just userName and password pair + [SemanticAttributes.DB_USER]: + this.config?.userName ?? + this.config?.authentication?.options?.userName, + [SemanticAttributes.DB_STATEMENT]: sql, + [SemanticAttributes.DB_SQL_TABLE]: request.table, + }, + } + ); + + const endSpan = once((err?: any) => { + request.removeListener('done', incrementStatementCount); + request.removeListener('doneInProc', incrementStatementCount); + request.removeListener('doneProc', incrementProcCount); + request.removeListener('error', endSpan); + this.removeListener('end', endSpan); + + span.setAttribute('tedious.procedure_count', procCount); + span.setAttribute('tedious.statement_count', statementCount); + if (err) { + span.setStatus({ + code: api.SpanStatusCode.ERROR, + message: err.message, + }); + } + span.end(); + }); + + request.on('done', incrementStatementCount); + request.on('doneInProc', incrementStatementCount); + request.on('doneProc', incrementProcCount); + request.once('error', endSpan); + this.on('end', endSpan); + + if (typeof request.callback === 'function') { + thisPlugin._wrap( + request, + 'callback', + thisPlugin._patchCallbackQuery(endSpan) + ); + } else { + thisPlugin._diag.error('Expected request.callback to be a function'); + } + + return api.context.with( + api.trace.setSpan(api.context.active(), span), + originalMethod, + this, + ...arguments + ); + } + + Object.defineProperty(patchedMethod, 'length', { + value: originalMethod.length, + writable: false, + }); + + return patchedMethod; + }; + } + + private _patchCallbackQuery(endSpan: Function) { + return (originalCallback: Function) => { + return function ( + this: any, + err: Error | undefined | null, + rowCount?: number, + rows?: any + ) { + endSpan(err); + return originalCallback.apply(this, arguments); + }; + }; + } +} diff --git a/plugins/node/instrumentation-tedious/src/types.ts b/plugins/node/instrumentation-tedious/src/types.ts new file mode 100644 index 0000000000..3342997061 --- /dev/null +++ b/plugins/node/instrumentation-tedious/src/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright The 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 { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +export type TediousInstrumentationConfig = InstrumentationConfig; diff --git a/plugins/node/instrumentation-tedious/src/utils.ts b/plugins/node/instrumentation-tedious/src/utils.ts new file mode 100644 index 0000000000..36e65129a3 --- /dev/null +++ b/plugins/node/instrumentation-tedious/src/utils.ts @@ -0,0 +1,53 @@ +/* + * Copyright The 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. + */ + +/** + * The span name SHOULD be set to a low cardinality value + * representing the statement executed on the database. + * + * @returns Operation executed on Tedious Connection. Does not map to SQL statement in any way. + */ +export function getSpanName( + operation: string, + db: string | undefined, + sql: string | undefined, + bulkLoadTable: string | undefined +): string { + if (operation === 'execBulkLoad' && bulkLoadTable && db) { + return `${operation} ${bulkLoadTable} ${db}`; + } + if (operation === 'callProcedure') { + // `sql` refers to procedure name with `callProcedure` + if (db) { + return `${operation} ${sql} ${db}`; + } + return `${operation} ${sql}`; + } + // do not use `sql` in general case because of high-cardinality + if (db) { + return `${operation} ${db}`; + } + return `${operation}`; +} + +export const once = (fn: Function) => { + let called = false; + return (...args: unknown[]) => { + if (called) return; + called = true; + return fn(...args); + }; +}; diff --git a/plugins/node/instrumentation-tedious/test/api.ts b/plugins/node/instrumentation-tedious/test/api.ts new file mode 100644 index 0000000000..d01a986d31 --- /dev/null +++ b/plugins/node/instrumentation-tedious/test/api.ts @@ -0,0 +1,333 @@ +/* + * Copyright The 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 assert from 'assert'; +import { promisify } from 'util'; +import type { Connection, Request, TYPES, ConnectionConfig } from 'tedious'; + +type Method = keyof Connection & ('execSql' | 'execSqlBatch' | 'prepare'); +export type tedious = { + Connection: typeof Connection; + Request: typeof Request; + TYPES: typeof TYPES; + ConnectionConfig: ConnectionConfig; +}; + +export const makeApi = (tedious: tedious) => { + const fullName = (resource: string) => { + assert.strictEqual(typeof resource, 'string'); + return `[dbo].[${resource}]`; + }; + + const createConnection = (config: ConnectionConfig): Promise => { + return new Promise((resolve, reject) => { + const connection = new tedious.Connection(config); + + connection.on('connect', err => { + if (err) { + return reject(err); + } + return resolve(connection); + }); + + // <8.3.0 autoconnects + // `state` and `STATE` are private API + if ((connection as any).state !== (connection as any).STATE.CONNECTING) { + connection.connect(); + } + }); + }; + const closeConnection = (connection: Connection): Promise => { + assert(connection); + assert(connection.once); + return new Promise((resolve, reject) => { + connection.once('end', err => { + if (err) { + return reject(err); + } + return resolve(true); + }); + connection.close(); + }); + }; + + const query = ( + connection: Connection, + params: string, + method: Method = 'execSql' + ): Promise => { + return new Promise((resolve, reject) => { + const result: any[] = []; + const request = new tedious.Request(params, (err, rowCount, rows) => { + if (err) { + return reject(err); + } else { + resolve(result); + } + }); + + // 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:')); + // request.on('done', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'done:')); + // request.on('doneInProc', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'doneInProc:')); + // request.on('doneProc', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'doneProc:')); + // request.on('prepared', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'prepared:')); + // request.on('columnMetadata', console.log.bind(console, /*request.sqlTextOrProcedure,*/ 'columnMetadata:')); + + request.on('row', (rows: any[]) => { + result.push(...rows.map(r => r.value)); + }); + + connection[method](request); + }); + }; + + const storedProcedure = { + procedureName: '[dbo].[test_proced]', + create: (connection: Connection): Promise => { + return new Promise((resolve, reject) => { + const sql = ` + CREATE OR ALTER PROCEDURE ${storedProcedure.procedureName} + @inputVal varchar(30), + @outputCount int OUTPUT + AS + set @outputCount = LEN(@inputVal);`.trim(); + + const request = new tedious.Request(sql, err => { + if (err) { + return reject(err); + } + + resolve(true); + }); + + connection.execSql(request); + }); + }, + call: (connection: Connection): Promise => { + return new Promise((resolve, reject) => { + const result: any = {}; + const request = new tedious.Request( + storedProcedure.procedureName, + err => { + if (err) { + return reject(err); + } + resolve(result); + } + ); + + request.addParameter('inputVal', tedious.TYPES.VarChar, 'hello world'); + request.addOutputParameter('outputCount', tedious.TYPES.Int); + + request.on('returnValue', (paramName, value, metadata) => { + result[paramName] = value; + }); + + connection.callProcedure(request); + }); + }, + }; + + const preparedSQL = { + tableName: '[dbo].[test_prepared]', + createTable: (connection: Connection): Promise => { + return new Promise((resolve, reject) => { + const sql = ` + if not exists(SELECT * FROM sysobjects WHERE name='test_prepared' AND xtype='U') + CREATE TABLE ${preparedSQL.tableName} (c1 int, c2 int)`.trim(); + const request = new tedious.Request(sql, (err, rowCount) => { + if (err) { + return reject(err); + } + resolve(true); + }); + + connection.execSql(request); + }); + }, + prepare: (connection: Connection): Promise => { + return new Promise((resolve, reject) => { + const sql = `INSERT INTO ${preparedSQL.tableName} VALUES (@val1, @val2)`; + const request = new tedious.Request(sql, (err, rowCount) => { + if (err) { + return reject(err); + } + }); + + // Types for tedious doesn't take this usecase into account, thus the cast to any + (request as any).addParameter('val1', tedious.TYPES.Int); + (request as any).addParameter('val2', tedious.TYPES.Int); + + request.on('prepared', () => { + resolve(request); + }); + + connection.prepare(request); + }); + }, + execute: (connection: Connection, request: Request): Promise => { + return new Promise((resolve, reject) => { + request.on('error', reject); + request.on('requestCompleted', () => { + resolve(true); + }); + connection.execute(request, { val1: 1, val2: 2 }); + }); + }, + }; + + /* + Connection has `inTransaction` boolean and `transactionDepth` property, but the + reliablility of those are questionable with `abortTransactionOnError` option enabled. + Usecases to test for in the future: + */ + const transaction = { + tableName: '[dbo].[test_transact]', + execute: async (connection: Connection) => { + const tx = transaction.api(connection); + await tx.begin(); + await query( + connection, + `CREATE TABLE ${transaction.tableName} (c1 int UNIQUE)` + ); + await query( + connection, + `INSERT INTO ${transaction.tableName} VALUES ('1')` + ); + await tx.commit(); + + return query(connection, `SELECT * FROM ${transaction.tableName}`); + }, + fail: async (connection: Connection) => { + const tx = transaction.api(connection); + await tx.begin(); + await query( + connection, + `CREATE TABLE ${transaction.tableName} (c1 int UNIQUE)` + ); + await query( + connection, + `INSERT INTO ${transaction.tableName} VALUES ('1')` + ); + await query( + connection, + `INSERT INTO ${transaction.tableName} VALUES ('1')` + ).catch(() => {}); + await tx.rollback(); + return query(connection, `SELECT * FROM ${transaction.tableName}`).catch( + () => true + ); + }, + api: (connection: Connection) => { + return { + begin: () => { + return promisify(connection.beginTransaction).call(connection); + }, + commit: () => { + return promisify(connection.commitTransaction).call(connection); + }, + rollback: () => { + return promisify(connection.rollbackTransaction).call(connection); + }, + }; + }, + }; + + const bulkLoad = { + tableName: '[dbo].[test_bulk]', + createTable: (connection: Connection): Promise => { + return new Promise((resolve, reject) => { + const sql = ` + if not exists(SELECT * FROM sysobjects WHERE name='test_bulk' AND xtype='U') + CREATE TABLE ${bulkLoad.tableName} ([c1] [int] DEFAULT 58, [c2] [varchar](30))`.trim(); + const request = new tedious.Request(sql, (err, rowCount) => { + if (err) { + return reject(err); + } + resolve(true); + }); + + connection.execSql(request); + }); + }, + execute: (connection: Connection): Promise => { + return new Promise((resolve, reject) => { + const requestDoneCb = (err: any, rowCount: number) => { + if (err) { + return reject(err); + } + resolve(rowCount); + }; + // <2.2.0 didn't take bulkOptions + const request = + connection.newBulkLoad.length === 2 + ? connection.newBulkLoad(bulkLoad.tableName, requestDoneCb) + : (connection.newBulkLoad as any)( + bulkLoad.tableName, + { keepNulls: true }, + requestDoneCb + ); + + request.addColumn('c1', tedious.TYPES.Int, { nullable: true }); + request.addColumn('c2', tedious.TYPES.NVarChar, { + length: 50, + nullable: true, + }); + + if (connection.execBulkLoad.length === 1) { + // required in <=11.5. not supported in 14 + request.addRow({ c1: 1 }); + request.addRow({ c1: 2, c2: 'hello' }); + return connection.execBulkLoad(request); + } + + (connection.execBulkLoad as any)(request, [ + { c1: 1 }, + { c1: 2, c2: 'hello' }, + ]); + }); + }, + }; + + const cleanup = (connection: Connection) => { + return query( + connection, + ` + if exists(SELECT * FROM sysobjects WHERE name='test_prepared' AND xtype='U') DROP TABLE ${preparedSQL.tableName}; + if exists(SELECT * FROM sysobjects WHERE name='test_bulk' AND xtype='U') DROP TABLE ${bulkLoad.tableName}; + 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() + ); + }; + + return { + fullName, + bulkLoad, + cleanup, + closeConnection, + createConnection, + preparedSQL, + query, + storedProcedure, + transaction, + }; +}; + +export default makeApi; diff --git a/plugins/node/instrumentation-tedious/test/instrumentation.test.ts b/plugins/node/instrumentation-tedious/test/instrumentation.test.ts new file mode 100644 index 0000000000..245f2d9f3c --- /dev/null +++ b/plugins/node/instrumentation-tedious/test/instrumentation.test.ts @@ -0,0 +1,393 @@ +/* + * Copyright The 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 { context, trace, SpanStatusCode, SpanKind } from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import * as util from 'util'; +import * as testUtils from '@opentelemetry/contrib-test-utils'; +import { + BasicTracerProvider, + InMemorySpanExporter, + ReadableSpan, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import * as assert from 'assert'; +import { TediousInstrumentation } from '../src'; +import makeApi from './api'; +import type { Connection, ConnectionConfig } from 'tedious'; + +process.env.RUN_MSSQL_TESTS = 'true'; + +const port = Number(process.env.MSSQL_PORT) || 1433; +const database = process.env.MSSQL_DATABASE || 'master'; +const host = process.env.MSSQL_HOST || '127.0.0.1'; +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: ConnectionConfig & { userName: string; password: string } = { + userName: user, + password, + server: host, + authentication: { + type: 'default', + options: { + userName: user, + password, + }, + }, + options: { + port, + database, + encrypt: true, + // Required for <11.0.8 + trustServerCertificate: true, + rowCollectionOnRequestCompletion: true, + rowCollectionOnDone: true, + }, +}; + +describe('tedious', () => { + let tedious: any; + let contextManager: AsyncHooksContextManager; + let connection: Connection; + const provider = new BasicTracerProvider(); + const shouldTest = process.env.RUN_MSSQL_TESTS; // For CI: assumes local db is already available + const shouldTestLocally = process.env.RUN_MSSQL_TESTS_LOCAL; // For local: spins up local db via docker + const memoryExporter = new InMemorySpanExporter(); + + before(function (done) { + if (!(shouldTest || shouldTestLocally)) { + // this.skip() workaround + // https://github.com/mochajs/mocha/issues/2683#issuecomment-375629901 + this.test!.parent!.pending = true; + this.skip(); + } + provider.addSpanProcessor(new SimpleSpanProcessor(memoryExporter)); + if (shouldTestLocally) { + testUtils.startDocker('mssql'); + // wait 15 seconds for docker container to start + this.timeout(20000); + setTimeout(done, 15000); + } else { + done(); + } + }); + + after(function () { + if (shouldTestLocally) { + this.timeout(5000); + testUtils.cleanUpDocker('mssql'); + } + }); + + beforeEach(async function () { + // connecting often takes more time even if the DB is running locally + this.timeout(10000); + instrumentation.disable(); + contextManager = new AsyncHooksContextManager().enable(); + context.setGlobalContextManager(contextManager); + instrumentation.setTracerProvider(provider); + instrumentation.enable(); + tedious = makeApi(require('tedious')); + connection = await tedious.createConnection(config).catch((err: any) => { + console.error('with config:', config); + throw err; + }); + await tedious.cleanup(connection); + memoryExporter.reset(); + }); + + afterEach(async () => { + context.disable(); + memoryExporter.reset(); + instrumentation.disable(); + if (connection) { + await tedious.closeConnection(connection); + } + }); + + it('should instrument execSql calls', async () => { + const queryString = "SELECT 42, 'hello world'"; + const PARENT_NAME = 'parentSpan'; + const parentSpan = provider.getTracer('default').startSpan(PARENT_NAME); + assert.deepStrictEqual( + await context.with(trace.setSpan(context.active(), parentSpan), () => + tedious.query(connection, queryString) + ), + [42, 'hello world'] + ); + parentSpan.end(); + + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSql master', + sql: queryString, + parentSpan, + }); + + assert.strictEqual(spans[1].name, PARENT_NAME); + }); + + it('should catch errors', async () => { + const queryString = 'select !'; + + await assertRejects( + () => tedious.query(connection, queryString), + /incorrect syntax/i + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSql master', + sql: queryString, + error: /incorrect syntax/i, + statementCount: 0, + }); + }); + + it('should instrument execSql calls containing multiple queries', async () => { + /* + Since we do not know how many queries are there without parsing the request + there may be cases where there is more than one SQL query done in the context + of one span. + */ + const queryString = 'SELECT 42; SELECT 42; SELECT 42;'; + assert.deepStrictEqual( + await tedious.query(connection, queryString), + [42, 42, 42] + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSql master', + sql: queryString, + procCount: 1, + statementCount: 3, + }); + }); + + it('should instrument execSqlBatch calls containing multiple queries', async () => { + const queryString = 'SELECT 42; SELECT 42; SELECT 42;'; + assert.deepStrictEqual( + await tedious.query(connection, queryString, 'execSqlBatch'), + [42, 42, 42] + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSqlBatch master', + sql: queryString, + procCount: 0, + statementCount: 3, + }); + }); + + it('should instrument stored procedure calls', async () => { + assert.strictEqual(await tedious.storedProcedure.create(connection), true); + assert.deepStrictEqual(await tedious.storedProcedure.call(connection), { + outputCount: 11, + }); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSql master', + sql: /create or alter procedure/i, + }); + assertSpan(spans[1], { + name: `callProcedure ${tedious.storedProcedure.procedureName} master`, + sql: tedious.storedProcedure.procedureName, + }); + }); + + it('should instrument prepared statement calls', async () => { + assert.strictEqual(await tedious.preparedSQL.createTable(connection), true); + const request = await tedious.preparedSQL.prepare(connection); + assert.strictEqual( + await tedious.preparedSQL.execute(connection, request), + true + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 3, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSql master', + sql: /create table/i, + statementCount: 2, + }); + assertSpan(spans[1], { + name: 'prepare master', + sql: /INSERT INTO/, + }); + assertSpan(spans[2], { + name: 'execute master', + sql: /INSERT INTO/, + }); + }); + + it('should track database changes', async () => { + const sql = { + create: 'create database temp_otel_db;', + use: 'use temp_otel_db;', + select: "SELECT 42, 'hello world'", + }; + await tedious.query(connection, sql.create); + await tedious.query(connection, sql.use); + assert.deepStrictEqual(await tedious.query(connection, sql.select), [ + 42, + 'hello world', + ]); + + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 3, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSql master', + sql: sql.create, + }); + assertSpan(spans[1], { + name: 'execSql master', + sql: sql.use, + }); + assertSpan(spans[2], { + name: 'execSql temp_otel_db', + sql: sql.select, + database: 'temp_otel_db', + }); + }); + + it('should instrument BulkLoads', async () => { + assert.strictEqual(await tedious.bulkLoad.createTable(connection), true); + assert.strictEqual(await tedious.bulkLoad.execute(connection), 2); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 3, 'Received incorrect number of spans'); + + assertSpan(spans[0], { + name: 'execSql master', + sql: /create table/i, + statementCount: 2, + }); + assertSpan(spans[1], { + name: 'execSqlBatch master', + sql: /insert bulk/, + procCount: 0, + }); + assertSpan(spans[2], { + name: 'execBulkLoad [dbo].[test_bulk] master', + procCount: 0, + table: '[dbo].[test_bulk]', + }); + }); +}); + +const assertMatch = (actual: string | undefined, expected: RegExp) => { + assert( + actual && expected.test(actual), + `Expected ${util.inspect(actual)} to match ${expected}` + ); +}; + +const assertRejects = ( + asyncFn: () => Promise, + expectedMessageRegexp: RegExp | undefined +) => { + const error = new Error('Missing expected rejection.'); + return Promise.resolve() + .then(() => asyncFn()) + .then(() => { + throw error; + }) + .catch(err => { + if (err === error) { + throw error; + } + if (expectedMessageRegexp) { + assertMatch(err?.message || err, expectedMessageRegexp); + } + }); +}; + +function assertSpan(span: ReadableSpan, expected: any) { + assert(span); + assert.strictEqual(span.name, expected.name); + assert.strictEqual(span.kind, SpanKind.CLIENT); + assert.strictEqual(span.attributes[SemanticAttributes.DB_SYSTEM], 'mssql'); + assert.strictEqual( + span.attributes[SemanticAttributes.DB_NAME], + expected.database ?? database + ); + assert.strictEqual(span.attributes[SemanticAttributes.NET_PEER_PORT], port); + assert.strictEqual(span.attributes[SemanticAttributes.NET_PEER_NAME], host); + assert.strictEqual(span.attributes[SemanticAttributes.DB_USER], user); + assert.strictEqual( + span.attributes['tedious.procedure_count'], + expected.procCount ?? 1, + 'Invalid procedure_count' + ); + assert.strictEqual( + span.attributes['tedious.statement_count'], + expected.statementCount ?? 1, + 'Invalid statement_count' + ); + if (expected.parentSpan) { + assert.strictEqual( + span.parentSpanId, + expected.parentSpan.spanContext().spanId + ); + } + assert.strictEqual( + span.attributes[SemanticAttributes.DB_SQL_TABLE], + expected.table + ); + if (expected.sql) { + if (expected.sql instanceof RegExp) { + assertMatch( + span.attributes[SemanticAttributes.DB_STATEMENT] as string | undefined, + expected.sql + ); + } else { + assert.strictEqual( + span.attributes[SemanticAttributes.DB_STATEMENT], + expected.sql + ); + } + } else { + assert.strictEqual( + span.attributes[SemanticAttributes.DB_STATEMENT], + undefined + ); + } + if (expected.error) { + assert( + expected.error.test(span.status.message), + `Expected "${span.status.message}" to match ${expected.error}` + ); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + } else { + assert.strictEqual(span.status.message, undefined); + assert.strictEqual(span.status.code, SpanStatusCode.UNSET); + } +} diff --git a/plugins/node/instrumentation-tedious/tsconfig.json b/plugins/node/instrumentation-tedious/tsconfig.json new file mode 100644 index 0000000000..28be80d266 --- /dev/null +++ b/plugins/node/instrumentation-tedious/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +}