diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 586c55da0c..1260ee8fc4 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -44,6 +44,8 @@ components: - rauno56 plugins/node/opentelemetry-instrumentation-redis: - blumamir + plugins/node/opentelemetry-instrumentation-redis-4: + - blumamir plugins/node/opentelemetry-instrumentation-restify: - rauno56 plugins/node/opentelemetry-instrumentation-router: diff --git a/.github/workflows/test-all-versions.yml b/.github/workflows/test-all-versions.yml index e48b0a66e4..b97006a8f1 100644 --- a/.github/workflows/test-all-versions.yml +++ b/.github/workflows/test-all-versions.yml @@ -24,11 +24,13 @@ jobs: --ignore @opentelemetry/instrumentation-tedious --ignore @opentelemetry/instrumentation-amqplib --ignore @opentelemetry/instrumentation-mongodb + --ignore @opentelemetry/instrumentation-redis-4 - node: "10" lerna-extra-args: >- --ignore @opentelemetry/instrumentation-aws-sdk --ignore @opentelemetry/instrumentation-pino --ignore @opentelemetry/instrumentation-mongodb + --ignore @opentelemetry/instrumentation-redis-4 runs-on: ubuntu-latest services: memcached: diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 11a52e6df7..689a0f3f65 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -17,10 +17,15 @@ jobs: --ignore @opentelemetry/instrumentation-pino --ignore @opentelemetry/instrumentation-tedious --ignore @opentelemetry/instrumentation-amqplib + --ignore @opentelemetry/instrumentation-redis-4 - node: "10" lerna-extra-args: >- --ignore @opentelemetry/instrumentation-aws-sdk --ignore @opentelemetry/instrumentation-pino + --ignore @opentelemetry/instrumentation-redis-4 + - node: "12" + lerna-extra-args: >- + --ignore @opentelemetry/instrumentation-redis-4 runs-on: ubuntu-latest services: memcached: diff --git a/metapackages/auto-instrumentations-node/package.json b/metapackages/auto-instrumentations-node/package.json index 60190aae49..cfa7882f5d 100644 --- a/metapackages/auto-instrumentations-node/package.json +++ b/metapackages/auto-instrumentations-node/package.json @@ -73,6 +73,7 @@ "@opentelemetry/instrumentation-pg": "^0.29.0", "@opentelemetry/instrumentation-pino": "^0.29.0", "@opentelemetry/instrumentation-redis": "^0.30.0", + "@opentelemetry/instrumentation-redis-4": "^0.29.0", "@opentelemetry/instrumentation-restify": "^0.28.0", "@opentelemetry/instrumentation-winston": "^0.28.0" } diff --git a/metapackages/auto-instrumentations-node/src/utils.ts b/metapackages/auto-instrumentations-node/src/utils.ts index 38dd2da472..ba9d64473a 100644 --- a/metapackages/auto-instrumentations-node/src/utils.ts +++ b/metapackages/auto-instrumentations-node/src/utils.ts @@ -41,7 +41,8 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core' import { NetInstrumentation } from '@opentelemetry/instrumentation-net'; import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino'; -import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis'; +import { RedisInstrumentation as RedisInstrumentationV2 } from '@opentelemetry/instrumentation-redis'; +import { RedisInstrumentation as RedisInstrumentationV4 } from '@opentelemetry/instrumentation-redis-4'; import { RestifyInstrumentation } from '@opentelemetry/instrumentation-restify'; import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston'; @@ -72,7 +73,8 @@ const InstrumentationMap = { '@opentelemetry/instrumentation-net': NetInstrumentation, '@opentelemetry/instrumentation-pg': PgInstrumentation, '@opentelemetry/instrumentation-pino': PinoInstrumentation, - '@opentelemetry/instrumentation-redis': RedisInstrumentation, + '@opentelemetry/instrumentation-redis': RedisInstrumentationV2, + '@opentelemetry/instrumentation-redis-4': RedisInstrumentationV4, '@opentelemetry/instrumentation-restify': RestifyInstrumentation, '@opentelemetry/instrumentation-winston': WinstonInstrumentation, }; diff --git a/metapackages/auto-instrumentations-node/test/utils.test.ts b/metapackages/auto-instrumentations-node/test/utils.test.ts index e4b392da9e..aecfd1fd49 100644 --- a/metapackages/auto-instrumentations-node/test/utils.test.ts +++ b/metapackages/auto-instrumentations-node/test/utils.test.ts @@ -51,10 +51,11 @@ describe('utils', () => { '@opentelemetry/instrumentation-pg', '@opentelemetry/instrumentation-pino', '@opentelemetry/instrumentation-redis', + '@opentelemetry/instrumentation-redis-4', '@opentelemetry/instrumentation-restify', '@opentelemetry/instrumentation-winston', ]; - assert.strictEqual(instrumentations.length, 28); + assert.strictEqual(instrumentations.length, 29); for (let i = 0, j = instrumentations.length; i < j; i++) { assert.strictEqual( instrumentations[i].instrumentationName, diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/.eslintignore b/plugins/node/opentelemetry-instrumentation-redis-4/.eslintignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/.eslintignore @@ -0,0 +1 @@ +build diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/.eslintrc.js b/plugins/node/opentelemetry-instrumentation-redis-4/.eslintrc.js new file mode 100644 index 0000000000..f756f4488b --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "env": { + "mocha": true, + "node": true + }, + ...require('../../../eslint.config.js') +} diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/.npmignore b/plugins/node/opentelemetry-instrumentation-redis-4/.npmignore new file mode 100644 index 0000000000..9505ba9450 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/.npmignore @@ -0,0 +1,4 @@ +/bin +/coverage +/doc +/test diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/.tav.yml b/plugins/node/opentelemetry-instrumentation-redis-4/.tav.yml new file mode 100644 index 0000000000..f478be1116 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/.tav.yml @@ -0,0 +1,8 @@ +redis: + + jobs: + - versions: "^4.0.0" + commands: npm run test + + # Fix missing `contrib-test-utils` package + pretest: npm run --prefix ../../../ lerna:link diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/LICENSE b/plugins/node/opentelemetry-instrumentation-redis-4/LICENSE new file mode 100644 index 0000000000..e50e8c80f9 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/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 [2022] 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 + + 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/opentelemetry-instrumentation-redis-4/README.md b/plugins/node/opentelemetry-instrumentation-redis-4/README.md new file mode 100644 index 0000000000..7d4870a511 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/README.md @@ -0,0 +1,88 @@ +# OpenTelemetry redis Instrumentation for Node.js + +[![NPM Published Version][npm-img]][npm-url] +[![Apache License][license-image]][license-image] + +This module provides automatic instrumentation for the [`redis@^4.0.0`](https://github.com/NodeRedis/node_redis) module, which may be loaded using the [`@opentelemetry/sdk-trace-node`](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-node) package and is included in the [`@opentelemetry/auto-instrumentations-node`](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node) bundle. + +If total installation size is not constrained, it is recommended to use the [`@opentelemetry/auto-instrumentations-node`](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node) bundle with [@opentelemetry/sdk-node](`https://www.npmjs.com/package/@opentelemetry/sdk-node`) for the most seamless instrumentation experience. + +Compatible with OpenTelemetry JS API and SDK `1.0+`. + +## Installation + +```bash +npm install --save @opentelemetry/instrumentation-redis-4 +``` + +### Supported Versions + +This package supports `redis@^4.0.0` +For versions `redis@^2.6.0` and `redis@^3.0.0`, please use `@opentelemetry/instrumentation-redis` + +## Usage + +OpenTelemetry Redis 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 [redis](https://www.npmjs.com/package/redis). + +To load a specific instrumentation (**redis** in this case), specify it in the registerInstrumentations' configuration + +```javascript +const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); +const { RedisInstrumentation } = require('@opentelemetry/instrumentation-redis-4'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); + +const provider = new NodeTracerProvider(); +provider.register(); + +registerInstrumentations({ + instrumentations: [ + new RedisInstrumentation(), + ], +}) +``` + +### Redis Instrumentation Options + +Redis instrumentation has a few options available to choose from. You can set the following: + +| Options | Type | Description | +| ----------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `dbStatementSerializer` | `DbStatementSerializer` (function) | Redis instrumentation will serialize the command to the `db.statement` attribute using the specified function. | +| `responseHook` | `RedisResponseCustomAttributeFunction` (function) | Function for adding custom attributes on db response. Receives params: `span, cmdName, cmdArgs, response` | +| `requireParentSpan` | `boolean` | Require parent to create redis span, default when unset is false. | + +#### Custom `db.statement` Serializer + +The instrumentation serializes the command into a Span attribute called +`db.statement`. The default serialization sets the attribute to the command +name, without the command arguments. + +It is also possible to define a custom serialization function. The function +will receive the command name and arguments and must return a string. + +Here is a simple example to serialize the command name and arguments: + +```javascript +const { RedisInstrumentation } = require('@opentelemetry/instrumentation-redis'); +const redisInstrumentation = new RedisInstrumentation({ + dbStatementSerializer: function (cmdName, cmdArgs) { + return [cmdName, ...cmdArgs].join(" "); + }, +}); +``` + +## 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-redis-4 +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-redis-4.svg diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/package.json b/plugins/node/opentelemetry-instrumentation-redis-4/package.json new file mode 100644 index 0000000000..ff5f7ecada --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/package.json @@ -0,0 +1,76 @@ +{ + "name": "@opentelemetry/instrumentation-redis-4", + "version": "0.29.0", + "description": "Automatic OpenTelemetry instrumentation for redis package version 4", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": "open-telemetry/opentelemetry-js-contrib", + "scripts": { + "test": "nyc ts-mocha -p tsconfig.json --require '@opentelemetry/contrib-test-utils' 'test/redis.test.ts'", + "test:debug": "cross-env RUN_REDIS_TESTS_LOCAL=true ts-mocha --inspect-brk --no-timeouts -p tsconfig.json 'test/redis.test.ts'", + "test:local": "cross-env RUN_REDIS_TESTS_LOCAL=true npm run test", + "test:docker:run": "docker run --rm -d --name otel-redis -p 63790:6379 redis:alpine", + "test:docker:stop": "docker stop otel-redis", + "test-all-versions": "tav", + "test-all-versions:local": "cross-env RUN_REDIS_TESTS_LOCAL=true npm run test-all-versions", + "tdd": "npm run test -- --watch-extensions ts --watch", + "clean": "rimraf build/*", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "precompile": "tsc --version && lerna run version:update --scope @opentelemetry/instrumentation-redis-4 --include-dependencies", + "prewatch": "npm run precompile", + "version:update": "node ../../../scripts/version-update.js", + "compile": "tsc -p .", + "prepare": "npm run compile" + }, + "keywords": [ + "instrumentation", + "nodejs", + "opentelemetry", + "profiling", + "redis", + "tracing" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts", + "doc", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "devDependencies": { + "@opentelemetry/api": "^1.0.0", + "@opentelemetry/context-async-hooks": "1.2.0", + "@opentelemetry/core": "1.2.0", + "@opentelemetry/contrib-test-utils": "^0.30.0", + "@opentelemetry/sdk-trace-base": "1.2.0", + "@opentelemetry/sdk-trace-node": "1.2.0", + "@types/mocha": "7.0.2", + "@types/node": "16.11.21", + "cross-env": "7.0.3", + "gts": "3.1.0", + "mocha": "7.2.0", + "nyc": "15.1.0", + "redis": "4.1.0", + "rimraf": "3.0.2", + "test-all-versions": "5.0.1", + "ts-mocha": "8.0.0", + "typescript": "4.3.5" + }, + "dependencies": { + "@opentelemetry/instrumentation": "^0.28.0", + "@opentelemetry/semantic-conventions": "^1.0.0" + } +} diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/src/index.ts b/plugins/node/opentelemetry-instrumentation-redis-4/src/index.ts new file mode 100644 index 0000000000..cad137182f --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from './instrumentation'; +export { RedisInstrumentationConfig } from './types'; diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/src/instrumentation.ts b/plugins/node/opentelemetry-instrumentation-redis-4/src/instrumentation.ts new file mode 100644 index 0000000000..9ffe4da639 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/src/instrumentation.ts @@ -0,0 +1,431 @@ +/* + * 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 { + diag, + trace, + context, + SpanKind, + Span, + SpanStatusCode, +} from '@opentelemetry/api'; +import { + isWrapped, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import { defaultDbStatementSerializer } from './utils'; +import { RedisInstrumentationConfig } from './types'; +import { VERSION } from './version'; +import { + DbSystemValues, + SemanticAttributes, +} from '@opentelemetry/semantic-conventions'; + +const OTEL_OPEN_SPANS = Symbol( + 'opentelemetry.instruemntation.redis.open_spans' +); +const MULTI_COMMAND_OPTIONS = Symbol( + 'opentelemetry.instruemntation.redis.multi_command_options' +); + +interface MutliCommandInfo { + span: Span; + commandName: string; + commandArgs: Array; +} + +const DEFAULT_CONFIG: RedisInstrumentationConfig = { + requireParentSpan: false, +}; + +export class RedisInstrumentation extends InstrumentationBase { + static readonly COMPONENT = 'redis'; + + constructor(protected override _config: RedisInstrumentationConfig = {}) { + super('@opentelemetry/instrumentation-redis-4', VERSION, _config); + } + + override setConfig(config: RedisInstrumentationConfig = {}) { + this._config = Object.assign({}, DEFAULT_CONFIG, config); + } + + protected init() { + // @node-redis/client is a new package introduced and consumed by 'redis 4.0.x' + // on redis@4.1.0 it was changed to @redis/client. + // we will instrument both packages + return [ + this._getInstrumentationNodeModuleDefinition('@redis/client'), + this._getInstrumentationNodeModuleDefinition('@node-redis/client'), + ]; + } + + private _getInstrumentationNodeModuleDefinition( + basePackageName: string + ): InstrumentationNodeModuleDefinition { + const commanderModuleFile = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/commander.js`, + ['^1.0.0'], + (moduleExports: any, moduleVersion?: string) => { + const transformCommandArguments = + moduleExports.transformCommandArguments; + if (!transformCommandArguments) { + this._diag.error( + 'internal instrumentation error, missing transformCommandArguments function' + ); + return moduleExports; + } + + // function name and signature changed in redis 4.1.0 from 'extendWithCommands' to 'attachCommands' + // the matching internal package names starts with 1.0.x (for redis 4.0.x) + const functionToPatch = moduleVersion?.startsWith('1.0.') + ? 'extendWithCommands' + : 'attachCommands'; + // this is the function that extend a redis client with a list of commands. + // the function patches the commandExecutor to record a span + this._diag.debug('Patching redis commands executor'); + if (isWrapped(moduleExports?.[functionToPatch])) { + this._unwrap(moduleExports, functionToPatch); + } + this._wrap( + moduleExports, + functionToPatch, + this._getPatchExtendWithCommands(transformCommandArguments) + ); + + return moduleExports; + }, + (moduleExports: any) => { + this._diag.debug('Unpatching redis commands executor'); + if (isWrapped(moduleExports?.extendWithCommands)) { + this._unwrap(moduleExports, 'extendWithCommands'); + } + if (isWrapped(moduleExports?.attachCommands)) { + this._unwrap(moduleExports, 'attachCommands'); + } + } + ); + + const multiCommanderModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/multi-command.js`, + ['^1.0.0'], + (moduleExports: any) => { + this._diag.debug('Patching redis multi commands executor'); + const redisClientMultiCommandPrototype = + moduleExports?.default?.prototype; + + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + this._wrap( + redisClientMultiCommandPrototype, + 'exec', + this._getPatchMultiCommandsExec() + ); + + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + this._wrap( + redisClientMultiCommandPrototype, + 'addCommand', + this._getPatchMultiCommandsAddCommand() + ); + + return moduleExports; + }, + (moduleExports: any) => { + this._diag.debug('Unpatching redis multi commands executor'); + const redisClientMultiCommandPrototype = + moduleExports?.default?.prototype; + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + } + ); + + const clientIndexModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/index.js`, + ['^1.0.0'], + (moduleExports: any) => { + this._diag.debug('Patching redis client'); + const redisClientPrototype = moduleExports?.default?.prototype; + + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + this._wrap( + redisClientPrototype, + 'multi', + this._getPatchRedisClientMulti() + ); + + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + this._wrap( + redisClientPrototype, + 'sendCommand', + this._getPatchRedisClientSendCommand() + ); + + return moduleExports; + }, + (moduleExports: any) => { + this._diag.debug('Unpatching redis client'); + const redisClientPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + } + ); + + return new InstrumentationNodeModuleDefinition( + basePackageName, + ['^1.0.0'], + (moduleExports: any, moduleVersion?: string) => { + diag.debug( + `Patching ${basePackageName}/client@${moduleVersion} (redis@^4.0.0)` + ); + return moduleExports; + }, + (_moduleExports: any, moduleVersion?: string) => { + diag.debug( + `Unpatching ${basePackageName}/client@${moduleVersion} (redis@^4.0.0)` + ); + }, + [commanderModuleFile, multiCommanderModule, clientIndexModule] + ); + } + + // serves both for redis 4.0.x where function name is extendWithCommands + // and redis ^4.1.0 where function name is attachCommands + private _getPatchExtendWithCommands(transformCommandArguments: Function) { + const plugin = this; + return function extendWithCommandsPatchWrapper(original: Function) { + return function extendWithCommandsPatch(this: any, config: any) { + if (config?.BaseClass?.name !== 'RedisClient') { + return original.apply(this, arguments); + } + + const origExecutor = config.executor; + config.executor = function ( + this: any, + command: any, + args: Array + ) { + const redisCommandArguments = transformCommandArguments( + command, + args + ).args; + return plugin._traceClientCommand( + origExecutor, + this, + arguments, + redisCommandArguments + ); + }; + return original.apply(this, arguments); + }; + }; + } + + private _getPatchMultiCommandsExec() { + const plugin = this; + return function execPatchWrapper(original: Function) { + return function execPatch(this: any) { + const execRes = original.apply(this, arguments); + if (typeof execRes?.then !== 'function') { + plugin._diag.error( + 'got non promise result when patching RedisClientMultiCommand.exec' + ); + return execRes; + } + + execRes.then((redisRes: unknown[]) => { + const openSpans = this[OTEL_OPEN_SPANS]; + if (!openSpans) { + return plugin._diag.error( + 'cannot find open spans to end for redis multi command' + ); + } + if (redisRes.length !== openSpans.length) { + return plugin._diag.error( + 'number of multi command spans does not match response from redis' + ); + } + for (let i = 0; i < openSpans.length; i++) { + const { span, commandName, commandArgs } = openSpans[i]; + const currCommandRes = redisRes[i]; + if (currCommandRes instanceof Error) { + plugin._endSpanWithResponse( + span, + commandName, + commandArgs, + null, + currCommandRes + ); + } else { + plugin._endSpanWithResponse( + span, + commandName, + commandArgs, + currCommandRes, + undefined + ); + } + } + }); + return execRes; + }; + }; + } + + private _getPatchMultiCommandsAddCommand() { + const plugin = this; + return function addCommandWrapper(original: Function) { + return function addCommandPatch(this: any, args: Array) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _getPatchRedisClientMulti() { + return function multiPatchWrapper(original: Function) { + return function multiPatch(this: any) { + const multiRes = original.apply(this, arguments); + multiRes[MULTI_COMMAND_OPTIONS] = this.options; + return multiRes; + }; + }; + } + + private _getPatchRedisClientSendCommand() { + const plugin = this; + return function sendCommandWrapper(original: Function) { + return function sendCommandPatch( + this: any, + args: Array + ) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _traceClientCommand( + origFunction: Function, + origThis: any, + origArguments: IArguments, + redisCommandArguments: Array + ) { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (hasNoParentSpan && this._config?.requireParentSpan) { + return origFunction.apply(origThis, origArguments); + } + + const clientOptions = origThis.options || origThis[MULTI_COMMAND_OPTIONS]; + + const commandName = redisCommandArguments[0] as string; // types also allows it to be a Buffer, but in practice it only string + const commandArgs = redisCommandArguments.slice(1); + + const dbStatementSerializer = + this._config?.dbStatementSerializer || defaultDbStatementSerializer; + + const attributes = { + [SemanticAttributes.DB_SYSTEM]: DbSystemValues.REDIS, + [SemanticAttributes.NET_PEER_NAME]: clientOptions?.socket?.host, + [SemanticAttributes.NET_PEER_PORT]: clientOptions?.socket?.port, + [SemanticAttributes.DB_CONNECTION_STRING]: clientOptions?.url, + }; + + try { + const dbStatement = dbStatementSerializer(commandName, commandArgs); + if (dbStatement != null) { + attributes[SemanticAttributes.DB_STATEMENT] = dbStatement; + } + } catch (e) { + this._diag.error('dbStatementSerializer throw an exception', e, { + commandName, + }); + } + + const span = this.tracer.startSpan( + `${RedisInstrumentation.COMPONENT}-${commandName}`, + { + kind: SpanKind.CLIENT, + attributes, + } + ); + + const res = origFunction.apply(origThis, origArguments); + if (typeof res?.then === 'function') { + res.then( + (redisRes: unknown) => { + this._endSpanWithResponse( + span, + commandName, + commandArgs, + redisRes, + undefined + ); + }, + (err: any) => { + this._endSpanWithResponse(span, commandName, commandArgs, null, err); + } + ); + } else { + const redisClientMultiCommand = res as { + [OTEL_OPEN_SPANS]?: Array; + }; + redisClientMultiCommand[OTEL_OPEN_SPANS] = + redisClientMultiCommand[OTEL_OPEN_SPANS] || []; + redisClientMultiCommand[OTEL_OPEN_SPANS]!.push({ + span, + commandName, + commandArgs, + }); + } + return res; + } + + private _endSpanWithResponse( + span: Span, + commandName: string, + commandArgs: Array, + response: unknown, + error: Error | undefined + ) { + if (!error && this._config.responseHook) { + try { + this._config.responseHook(span, commandName, commandArgs, response); + } catch (err) { + this._diag.error('responseHook throw an exception', err); + } + } + if (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); + } + span.end(); + } +} diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/src/types.ts b/plugins/node/opentelemetry-instrumentation-redis-4/src/types.ts new file mode 100644 index 0000000000..e15141a22e --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/src/types.ts @@ -0,0 +1,59 @@ +/* + * 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 { Span } from '@opentelemetry/api'; +import { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +/** + * Function that can be used to serialize db.statement tag + * @param cmdName - The name of the command (eg. set, get, mset) + * @param cmdArgs - Array of arguments passed to the command + * + * @returns serialized string that will be used as the db.statement attribute. + */ +export type DbStatementSerializer = ( + cmdName: string, + cmdArgs: Array +) => string; + +/** + * Function that can be used to add custom attributes to span on response from redis server + * @param span - The span created for the redis command, on which attributes can be set + * @param cmdName - The name of the command (eg. set, get, mset) + * @param cmdArgs - Array of arguments passed to the command + * @param response - The response object which is returned to the user who called this command. + * Can be used to set custom attributes on the span. + * The type of the response varies depending on the specific command. + */ +export interface RedisResponseCustomAttributeFunction { + ( + span: Span, + cmdName: string, + cmdArgs: Array, + response: unknown + ): void; +} + +export interface RedisInstrumentationConfig extends InstrumentationConfig { + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: DbStatementSerializer; + + /** Function for adding custom attributes on db response */ + responseHook?: RedisResponseCustomAttributeFunction; + + /** Require parent to create redis span, default when unset is false */ + requireParentSpan?: boolean; +} diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/src/utils.ts b/plugins/node/opentelemetry-instrumentation-redis-4/src/utils.ts new file mode 100644 index 0000000000..eda35eab65 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/src/utils.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 { DbStatementSerializer } from './types'; + +export const defaultDbStatementSerializer: DbStatementSerializer = cmdName => + cmdName; diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/test/redis.test.ts b/plugins/node/opentelemetry-instrumentation-redis-4/test/redis.test.ts new file mode 100644 index 0000000000..9701cd130d --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/test/redis.test.ts @@ -0,0 +1,453 @@ +/* + * 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 { + getTestSpans, + registerInstrumentationTesting, +} from '@opentelemetry/contrib-test-utils'; +import { RedisInstrumentation } from '../src'; +import * as assert from 'assert'; + +import { + redisTestConfig, + redisTestUrl, + shouldTest, + shouldTestLocal, +} from './utils'; +import * as testUtils from '@opentelemetry/contrib-test-utils'; + +const instrumentation = registerInstrumentationTesting( + new RedisInstrumentation() +); + +import { createClient } from 'redis'; +import { + Span, + SpanKind, + SpanStatusCode, + trace, + context, +} from '@opentelemetry/api'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import { RedisResponseCustomAttributeFunction } from '../src/types'; +import { hrTimeToMilliseconds } from '@opentelemetry/core'; + +describe('redis@^4.0.0', () => { + before(function () { + // needs to be "function" to have MochaContext "this" context + if (!shouldTest) { + // this.skip() workaround + // https://github.com/mochajs/mocha/issues/2683#issuecomment-375629901 + this.test!.parent!.pending = true; + this.skip(); + } + + if (shouldTestLocal) { + testUtils.startDocker('redis'); + } + }); + + after(() => { + if (shouldTestLocal) { + testUtils.cleanUpDocker('redis'); + } + }); + + let client: any; + + beforeEach(async () => { + client = createClient({ + url: redisTestUrl, + }); + await client.connect(); + }); + + afterEach(async () => { + await client?.disconnect(); + }); + + describe('redis commands', () => { + it('simple set and get', async () => { + await client.set('key', 'value'); + const value = await client.get('key'); + assert.strictEqual(value, 'value'); // verify we did not screw up the normal functionality + + const spans = getTestSpans(); + assert.strictEqual(spans.length, 2); + + const setSpan = spans.find(s => s.name.includes('SET')); + assert.ok(setSpan); + assert.strictEqual(setSpan?.kind, SpanKind.CLIENT); + assert.strictEqual(setSpan?.name, 'redis-SET'); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.DB_SYSTEM], + 'redis' + ); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.DB_STATEMENT], + 'SET' + ); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.DB_CONNECTION_STRING], + redisTestUrl + ); + + const getSpan = spans.find(s => s.name.includes('GET')); + assert.ok(getSpan); + assert.strictEqual(getSpan?.kind, SpanKind.CLIENT); + assert.strictEqual(getSpan?.name, 'redis-GET'); + assert.strictEqual( + getSpan?.attributes[SemanticAttributes.DB_SYSTEM], + 'redis' + ); + assert.strictEqual( + getSpan?.attributes[SemanticAttributes.DB_STATEMENT], + 'GET' + ); + assert.strictEqual( + getSpan?.attributes[SemanticAttributes.NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + getSpan?.attributes[SemanticAttributes.NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + getSpan?.attributes[SemanticAttributes.DB_CONNECTION_STRING], + redisTestUrl + ); + }); + + it('send general command', async () => { + const res = await client.sendCommand(['SET', 'key', 'value']); + assert.strictEqual(res, 'OK'); // verify we did not screw up the normal functionality + + const [setSpan] = getTestSpans(); + + assert.ok(setSpan); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.DB_STATEMENT], + 'SET' + ); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + setSpan?.attributes[SemanticAttributes.NET_PEER_PORT], + redisTestConfig.port + ); + }); + + it('command with error', async () => { + await client.set('string-key', 'string-value'); + await assert.rejects(async () => await client.incr('string-key')); + + const [_setSpan, incrSpan] = getTestSpans(); + + assert.ok(incrSpan); + assert.strictEqual(incrSpan?.status.code, SpanStatusCode.ERROR); + assert.strictEqual( + incrSpan?.status.message, + 'ERR value is not an integer or out of range' + ); + + const exceptions = incrSpan.events.filter( + event => event.name === 'exception' + ); + assert.strictEqual(exceptions.length, 1); + assert.strictEqual( + exceptions?.[0].attributes?.[SemanticAttributes.EXCEPTION_MESSAGE], + 'ERR value is not an integer or out of range' + ); + }); + }); + + describe('multi (transactions) commands', () => { + it('multi commands', async () => { + await client.set('another-key', 'another-value'); + const [setKeyReply, otherKeyValue] = await client + .multi() + .set('key', 'value') + .get('another-key') + .exec(); // ['OK', 'another-value'] + + assert.strictEqual(setKeyReply, 'OK'); // verify we did not screw up the normal functionality + assert.strictEqual(otherKeyValue, 'another-value'); // verify we did not screw up the normal functionality + + const [setSpan, multiSetSpan, multiGetSpan] = getTestSpans(); + + assert.ok(setSpan); + + assert.ok(multiSetSpan); + assert.strictEqual(multiSetSpan.name, 'redis-SET'); + assert.strictEqual( + multiSetSpan.attributes[SemanticAttributes.DB_STATEMENT], + 'SET' + ); + assert.strictEqual( + multiSetSpan?.attributes[SemanticAttributes.NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + multiSetSpan?.attributes[SemanticAttributes.NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + multiSetSpan?.attributes[SemanticAttributes.DB_CONNECTION_STRING], + redisTestUrl + ); + + assert.ok(multiGetSpan); + assert.strictEqual(multiGetSpan.name, 'redis-GET'); + assert.strictEqual( + multiGetSpan.attributes[SemanticAttributes.DB_STATEMENT], + 'GET' + ); + assert.strictEqual( + multiGetSpan?.attributes[SemanticAttributes.NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + multiGetSpan?.attributes[SemanticAttributes.NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + multiGetSpan?.attributes[SemanticAttributes.DB_CONNECTION_STRING], + redisTestUrl + ); + }); + + it('multi command with generic command', async () => { + const [setReply] = await client + .multi() + .addCommand(['SET', 'key', 'value']) + .exec(); + assert.strictEqual(setReply, 'OK'); // verify we did not screw up the normal functionality + + const [multiSetSpan] = getTestSpans(); + assert.ok(multiSetSpan); + assert.strictEqual( + multiSetSpan.attributes[SemanticAttributes.DB_STATEMENT], + 'SET' + ); + assert.strictEqual( + multiSetSpan?.attributes[SemanticAttributes.NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + multiSetSpan?.attributes[SemanticAttributes.NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + multiSetSpan?.attributes[SemanticAttributes.DB_CONNECTION_STRING], + redisTestUrl + ); + }); + + it('multi command with error', async () => { + const [setReply, incrReply] = await client + .multi() + .set('key', 'value') + .incr('key') + .exec(); // ['OK', 'ReplyError'] + assert.strictEqual(setReply, 'OK'); // verify we did not screw up the normal functionality + assert.ok(incrReply instanceof Error); // verify we did not screw up the normal functionality + + const [multiSetSpan, multiIncrSpan] = getTestSpans(); + + assert.ok(multiSetSpan); + assert.strictEqual(multiSetSpan.status.code, SpanStatusCode.UNSET); + + assert.ok(multiIncrSpan); + assert.strictEqual(multiIncrSpan.status.code, SpanStatusCode.ERROR); + assert.strictEqual( + multiIncrSpan.status.message, + 'ERR value is not an integer or out of range' + ); + }); + + it('duration covers create until server response', async () => { + await client.set('another-key', 'another-value'); + const multiClient = client.multi(); + let commands = multiClient.set('key', 'value'); + // wait 10 ms before adding next command + // simulate long operation + await new Promise(resolve => setTimeout(resolve, 10)); + commands = commands.get('another-key'); + const [setKeyReply, otherKeyValue] = await commands.exec(); // ['OK', 'another-value'] + + assert.strictEqual(setKeyReply, 'OK'); // verify we did not screw up the normal functionality + assert.strictEqual(otherKeyValue, 'another-value'); // verify we did not screw up the normal functionality + + const [_setSpan, multiSetSpan, multiGetSpan] = getTestSpans(); + // verify that commands span started when it was added to multi and not when "sent". + // they were called with 10 ms gap between them, so it should be reflected in the span start time + // could be nice feature in the future to capture an event for when it is actually sent + const startTimeDiff = + hrTimeToMilliseconds(multiGetSpan.startTime) - + hrTimeToMilliseconds(multiSetSpan.startTime); + assert.ok( + startTimeDiff >= 9, + `diff of start time should be >= 10 and it's ${startTimeDiff}` + ); + + const endTimeDiff = + hrTimeToMilliseconds(multiGetSpan.endTime) - + hrTimeToMilliseconds(multiSetSpan.endTime); + assert.ok(endTimeDiff < 10); // spans should all end together when multi response arrives from redis server + }); + + it('response hook for multi commands', async () => { + const responseHook: RedisResponseCustomAttributeFunction = ( + span: Span, + cmdName: string, + cmdArgs: Array, + response: unknown + ) => { + span.setAttribute('test.cmd.name', cmdName); + span.setAttribute('test.cmd.args', cmdArgs as string[]); + span.setAttribute('test.db.response', response as string); + }; + instrumentation.setConfig({ responseHook }); + + await client.set('another-key', 'another-value'); + const [setKeyReply, otherKeyValue] = await client + .multi() + .set('key', 'value') + .get('another-key') + .exec(); // ['OK', 'another-value'] + assert.strictEqual(setKeyReply, 'OK'); // verify we did not screw up the normal functionality + assert.strictEqual(otherKeyValue, 'another-value'); // verify we did not screw up the normal functionality + + const [_setSpan, multiSetSpan, multiGetSpan] = getTestSpans(); + + assert.ok(multiSetSpan); + assert.strictEqual(multiSetSpan.attributes['test.cmd.name'], 'SET'); + assert.deepStrictEqual(multiSetSpan.attributes['test.cmd.args'], [ + 'key', + 'value', + ]); + assert.strictEqual(multiSetSpan.attributes['test.db.response'], 'OK'); + + assert.ok(multiGetSpan); + assert.strictEqual(multiGetSpan.attributes['test.cmd.name'], 'GET'); + assert.deepStrictEqual(multiGetSpan.attributes['test.cmd.args'], [ + 'another-key', + ]); + assert.strictEqual( + multiGetSpan.attributes['test.db.response'], + 'another-value' + ); + }); + }); + + describe('config', () => { + describe('dbStatementSerializer', () => { + it('custom dbStatementSerializer', async () => { + const dbStatementSerializer = ( + cmdName: string, + cmdArgs: Array + ) => { + return `${cmdName} ${cmdArgs.join(' ')}`; + }; + + instrumentation.setConfig({ dbStatementSerializer }); + await client.set('key', 'value'); + const [span] = getTestSpans(); + assert.strictEqual( + span.attributes[SemanticAttributes.DB_STATEMENT], + 'SET key value' + ); + }); + + it('dbStatementSerializer throws', async () => { + const dbStatementSerializer = () => { + throw new Error('dbStatementSerializer error'); + }; + + instrumentation.setConfig({ dbStatementSerializer }); + await client.set('key', 'value'); + const [span] = getTestSpans(); + assert.ok(span); + assert.ok(!(SemanticAttributes.DB_STATEMENT in span.attributes)); + }); + }); + + describe('responseHook', () => { + it('valid response hook', async () => { + const responseHook: RedisResponseCustomAttributeFunction = ( + span: Span, + cmdName: string, + cmdArgs: Array, + response: unknown + ) => { + span.setAttribute('test.cmd.name', cmdName); + span.setAttribute('test.cmd.args', cmdArgs as string[]); + span.setAttribute('test.db.response', response as string); + }; + instrumentation.setConfig({ responseHook }); + await client.set('key', 'value'); + const [span] = getTestSpans(); + assert.ok(span); + assert.strictEqual(span.attributes['test.cmd.name'], 'SET'); + assert.deepStrictEqual(span.attributes['test.cmd.args'], [ + 'key', + 'value', + ]); + assert.strictEqual(span.attributes['test.db.response'], 'OK'); + }); + + it('responseHook throws', async () => { + const responseHook = () => { + throw new Error('responseHook error'); + }; + instrumentation.setConfig({ responseHook }); + const res = await client.set('key', 'value'); + assert.strictEqual(res, 'OK'); // package is still functional + const [span] = getTestSpans(); + assert.ok(span); + }); + }); + + describe('requireParentSpan', () => { + it('set to true', async () => { + instrumentation.setConfig({ requireParentSpan: true }); + + // no parent span => no redis span + const res = await client.set('key', 'value'); + assert.strictEqual(res, 'OK'); // verify we did not screw up the normal functionality + assert.ok(getTestSpans().length === 0); + + // has ambient span => redis span + const span = trace.getTracer('test').startSpan('test span'); + await context.with(trace.setSpan(context.active(), span), async () => { + const res = await client.set('key', 'value'); + assert.strictEqual(res, 'OK'); // verify we did not screw up the normal functionality + assert.ok(getTestSpans().length === 1); + }); + span.end(); + }); + }); + }); +}); diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/test/utils.ts b/plugins/node/opentelemetry-instrumentation-redis-4/test/utils.ts new file mode 100644 index 0000000000..cc0e0a6609 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/test/utils.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ +export const redisTestConfig = { + host: process.env.OPENTELEMETRY_REDIS_HOST || 'localhost', + port: +(process.env.OPENTELEMETRY_REDIS_PORT || 63790), +}; + +export const redisTestUrl = `redis://${redisTestConfig.host}:${redisTestConfig.port}`; + +export const shouldTestLocal = process.env.RUN_REDIS_TESTS_LOCAL; +export const shouldTest = process.env.RUN_REDIS_TESTS || shouldTestLocal; diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/tsconfig.json b/plugins/node/opentelemetry-instrumentation-redis-4/tsconfig.json new file mode 100644 index 0000000000..28be80d266 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis-4/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/plugins/node/opentelemetry-instrumentation-redis/README.md b/plugins/node/opentelemetry-instrumentation-redis/README.md index 2fe53a5693..4820d4a5e5 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/README.md +++ b/plugins/node/opentelemetry-instrumentation-redis/README.md @@ -18,7 +18,8 @@ npm install --save @opentelemetry/instrumentation-redis ### Supported Versions -- `^2.6.0 || ^3.0.0` (version `4` is not yet supported) +This package supports `redis@^2.6.0` and `redis@^3.0.0` +For version `redis@^4.0.0`, please use `@opentelemetry/instrumentation-redis-4` ## Usage diff --git a/plugins/node/opentelemetry-instrumentation-redis/package.json b/plugins/node/opentelemetry-instrumentation-redis/package.json index d844ef94e4..d0f02bb81b 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/package.json +++ b/plugins/node/opentelemetry-instrumentation-redis/package.json @@ -1,7 +1,7 @@ { "name": "@opentelemetry/instrumentation-redis", "version": "0.30.0", - "description": "OpenTelemetry redis automatic instrumentation package.", + "description": "Automatic OpenTelemetry instrumentation for redis package version 2 and 3", "main": "build/src/index.js", "types": "build/src/index.d.ts", "repository": "open-telemetry/opentelemetry-js-contrib", @@ -9,6 +9,8 @@ "test": "nyc ts-mocha -p tsconfig.json 'test/**/*.test.ts'", "test:debug": "cross-env RUN_REDIS_TESTS_LOCAL=true ts-mocha --inspect-brk --no-timeouts -p tsconfig.json 'test/**/*.test.ts'", "test:local": "cross-env RUN_REDIS_TESTS_LOCAL=true npm run test", + "test:docker:run": "docker run --rm -d --name otel-redis -p 63790:6379 redis:alpine", + "test:docker:stop": "docker stop otel-redis", "test-all-versions": "tav", "test-all-versions:local": "cross-env RUN_REDIS_TESTS_LOCAL=true npm run test-all-versions", "tdd": "npm run test -- --watch-extensions ts --watch",