diff --git a/package-lock.json b/package-lock.json index 47456dc892..56845cde2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26452,6 +26452,104 @@ "node": ">=4" } }, + "node_modules/redis-v4": { + "name": "redis", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "dev": true, + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/redis-v4/node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/redis-v4/node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "dev": true, + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/redis-v4/node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/redis-v4/node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/redis-v4/node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/redis-v4/node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/redis-v4/node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redis-v4/node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redis-v4/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/redis/node_modules/denque": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", @@ -33527,6 +33625,7 @@ "cross-env": "7.0.3", "nyc": "17.1.0", "redis": "3.1.2", + "redis-v4": "npm:redis@4.7.1", "rimraf": "5.0.10", "test-all-versions": "6.1.0", "typescript": "5.0.4" @@ -40900,6 +40999,7 @@ "cross-env": "7.0.3", "nyc": "17.1.0", "redis": "3.1.2", + "redis-v4": "npm:redis@4.7.1", "rimraf": "5.0.10", "test-all-versions": "6.1.0", "typescript": "5.0.4" @@ -56009,6 +56109,86 @@ "redis-errors": "^1.0.0" } }, + "redis-v4": { + "version": "npm:redis@4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "dev": true, + "requires": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + }, + "dependencies": { + "@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "dev": true, + "requires": {} + }, + "@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "dev": true, + "requires": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + } + }, + "@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "dev": true, + "requires": {} + }, + "@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "dev": true, + "requires": {} + }, + "@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "dev": true, + "requires": {} + }, + "@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "dev": true, + "requires": {} + }, + "cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "dev": true + }, + "generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, "reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", diff --git a/plugins/node/opentelemetry-instrumentation-redis-4/README.md b/plugins/node/opentelemetry-instrumentation-redis-4/README.md index a8e9449d4b..85b0516974 100644 --- a/plugins/node/opentelemetry-instrumentation-redis-4/README.md +++ b/plugins/node/opentelemetry-instrumentation-redis-4/README.md @@ -1,5 +1,7 @@ # OpenTelemetry redis Instrumentation for Node.js +> ⚠️ **DEPRECATED**: The support for `redis@4` instrumentation is now part of `@opentelemetry/instrumentation-redis`. please use it instead. + [![NPM Published Version][npm-img]][npm-url] [![Apache License][license-image]][license-image] diff --git a/plugins/node/opentelemetry-instrumentation-redis/.tav.yml b/plugins/node/opentelemetry-instrumentation-redis/.tav.yml index 81020bc78f..cbe37cff10 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/.tav.yml +++ b/plugins/node/opentelemetry-instrumentation-redis/.tav.yml @@ -1,5 +1,11 @@ redis: - versions: - include: '>=2.6.0 <4' - mode: latest-minors - commands: npm run test + - versions: + include: '>=2.6.0 <4' + mode: latest-minors + commands: npm run test-v2-v3-run + - versions: + include: '>=4 <5' + # "4.6.9" was a bad release that accidentally broke node v14 support. + exclude: "4.6.9" + mode: latest-minors + commands: npm run test-v4-run diff --git a/plugins/node/opentelemetry-instrumentation-redis/README.md b/plugins/node/opentelemetry-instrumentation-redis/README.md index b5c9f1cdc1..b2827e72f4 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/README.md +++ b/plugins/node/opentelemetry-instrumentation-redis/README.md @@ -3,7 +3,7 @@ [![NPM Published Version][npm-img]][npm-url] [![Apache License][license-image]][license-image] -This module provides automatic instrumentation for the [`redis`](https://github.com/NodeRedis/node_redis) module versions `>=2.6.0 <4`, 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. +This module provides automatic instrumentation for the [`redis`](https://github.com/NodeRedis/node_redis) module versions `>=2.6.0 <5`, 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. @@ -17,9 +17,7 @@ npm install --save @opentelemetry/instrumentation-redis ### Supported Versions -- [`redis`](https://www.npmjs.com/package/redis) versions `>=2.6.0 <4` - -For versions `redis@^4.0.0`, please use `@opentelemetry/instrumentation-redis-4` +- [`redis`](https://www.npmjs.com/package/redis) versions `>=2.6.0 <5` ## Usage diff --git a/plugins/node/opentelemetry-instrumentation-redis/package.json b/plugins/node/opentelemetry-instrumentation-redis/package.json index f80c700681..cae401a47f 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/package.json +++ b/plugins/node/opentelemetry-instrumentation-redis/package.json @@ -1,12 +1,16 @@ { "name": "@opentelemetry/instrumentation-redis", "version": "0.49.0", - "description": "OpenTelemetry instrumentation for `redis` v2 and v3 database client for Redis", + "description": "OpenTelemetry instrumentation for `redis` database client for Redis", "main": "build/src/index.js", "types": "build/src/index.d.ts", "repository": "open-telemetry/opentelemetry-js-contrib", "scripts": { - "test": "nyc mocha 'test/**/*.test.ts'", + "test": "npm run test-v2-v3 && npm run test-v4", + "test-v2-v3": "tav redis 3.1.2 npm run test-v2-v3-run", + "test-v4": "tav redis 4.7.1 npm run test-v4-run", + "test-v2-v3-run": "nyc mocha --no-clean --require '@opentelemetry/contrib-test-utils' 'test/v2-v3/*.test.ts'", + "test-v4-run": "nyc mocha --no-clean --require '@opentelemetry/contrib-test-utils' 'test/v4/*.test.ts'", "test:debug": "cross-env RUN_REDIS_TESTS_LOCAL=true mocha --inspect-brk --no-timeouts '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", @@ -59,6 +63,7 @@ "cross-env": "7.0.3", "nyc": "17.1.0", "redis": "3.1.2", + "redis-v4": "npm:redis@4.7.1", "rimraf": "5.0.10", "test-all-versions": "6.1.0", "typescript": "5.0.4" diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/index.ts b/plugins/node/opentelemetry-instrumentation-redis/src/index.ts index 8aee6d3cd5..50c62a0ebc 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/src/index.ts +++ b/plugins/node/opentelemetry-instrumentation-redis/src/index.ts @@ -17,7 +17,6 @@ export { RedisInstrumentation } from './instrumentation'; export type { DbStatementSerializer, - RedisCommand, RedisInstrumentationConfig, RedisResponseCustomAttributeFunction, } from './types'; diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/instrumentation.ts b/plugins/node/opentelemetry-instrumentation-redis/src/instrumentation.ts index 545b4622f7..14c2003dce 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/src/instrumentation.ts +++ b/plugins/node/opentelemetry-instrumentation-redis/src/instrumentation.ts @@ -13,211 +13,73 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { - isWrapped, - InstrumentationBase, - InstrumentationNodeModuleDefinition, - safeExecuteInTheMiddle, -} from '@opentelemetry/instrumentation'; -import { - endSpan, - getTracedCreateClient, - getTracedCreateStreamTrace, -} from './utils'; -import { RedisCommand, RedisInstrumentationConfig } from './types'; +import { InstrumentationBase } from '@opentelemetry/instrumentation'; +import { RedisInstrumentationConfig } from './types'; /** @knipignore */ import { PACKAGE_NAME, PACKAGE_VERSION } from './version'; -import { RedisPluginClientTypes } from './internal-types'; -import { SpanKind, context, trace } from '@opentelemetry/api'; -import { - DBSYSTEMVALUES_REDIS, - SEMATTRS_DB_CONNECTION_STRING, - SEMATTRS_DB_STATEMENT, - SEMATTRS_DB_SYSTEM, - SEMATTRS_NET_PEER_NAME, - SEMATTRS_NET_PEER_PORT, -} from '@opentelemetry/semantic-conventions'; -import { defaultDbStatementSerializer } from '@opentelemetry/redis-common'; +import { RedisInstrumentationV2_V3 } from './v2-v3/instrumentation'; +import { TracerProvider } from '@opentelemetry/api'; +import { RedisInstrumentationV4 } from './v4/instrumentation'; const DEFAULT_CONFIG: RedisInstrumentationConfig = { requireParentSpan: false, }; +// Wrapper RedisInstrumentation that address all supported versions export class RedisInstrumentation extends InstrumentationBase { - static readonly COMPONENT = 'redis'; + private instrumentationV2_V3: RedisInstrumentationV2_V3; + private instrumentationV4: RedisInstrumentationV4; + + // this is used to bypass a flaw in the base class constructor, which is calling + // member functions before the constructor has a chance to fully initialize the member variables. + private initialized = false; constructor(config: RedisInstrumentationConfig = {}) { - super(PACKAGE_NAME, PACKAGE_VERSION, { ...DEFAULT_CONFIG, ...config }); - } + const resolvedConfig = { ...DEFAULT_CONFIG, ...config }; + super(PACKAGE_NAME, PACKAGE_VERSION, resolvedConfig); - override setConfig(config: RedisInstrumentationConfig = {}) { - super.setConfig({ ...DEFAULT_CONFIG, ...config }); + this.instrumentationV2_V3 = new RedisInstrumentationV2_V3(this.getConfig()); + this.instrumentationV4 = new RedisInstrumentationV4(this.getConfig()); + this.initialized = true; } - protected init() { - return [ - new InstrumentationNodeModuleDefinition( - 'redis', - ['>=2.6.0 <4'], - moduleExports => { - if ( - isWrapped( - moduleExports.RedisClient.prototype['internal_send_command'] - ) - ) { - this._unwrap( - moduleExports.RedisClient.prototype, - 'internal_send_command' - ); - } - this._wrap( - moduleExports.RedisClient.prototype, - 'internal_send_command', - this._getPatchInternalSendCommand() - ); - - if (isWrapped(moduleExports.RedisClient.prototype['create_stream'])) { - this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); - } - this._wrap( - moduleExports.RedisClient.prototype, - 'create_stream', - this._getPatchCreateStream() - ); - - if (isWrapped(moduleExports.createClient)) { - this._unwrap(moduleExports, 'createClient'); - } - this._wrap( - moduleExports, - 'createClient', - this._getPatchCreateClient() - ); - return moduleExports; - }, - moduleExports => { - if (moduleExports === undefined) return; - this._unwrap( - moduleExports.RedisClient.prototype, - 'internal_send_command' - ); - this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); - this._unwrap(moduleExports, 'createClient'); - } - ), - ]; + override setConfig(config: RedisInstrumentationConfig = {}) { + const newConfig = { ...DEFAULT_CONFIG, ...config }; + super.setConfig(newConfig); + if (!this.initialized) { + return; + } + + this.instrumentationV2_V3.setConfig(newConfig); + this.instrumentationV4.setConfig(newConfig); } - /** - * Patch internal_send_command(...) to trace requests - */ - private _getPatchInternalSendCommand() { - const instrumentation = this; - return function internal_send_command(original: Function) { - return function internal_send_command_trace( - this: RedisPluginClientTypes, - cmd?: RedisCommand - ) { - // Versions of redis (2.4+) use a single options object - // instead of named arguments - if (arguments.length !== 1 || typeof cmd !== 'object') { - // We don't know how to trace this call, so don't start/stop a span - return original.apply(this, arguments); - } - - const config = instrumentation.getConfig(); - - const hasNoParentSpan = trace.getSpan(context.active()) === undefined; - if (config.requireParentSpan === true && hasNoParentSpan) { - return original.apply(this, arguments); - } - - const dbStatementSerializer = - config?.dbStatementSerializer || defaultDbStatementSerializer; - const span = instrumentation.tracer.startSpan( - `${RedisInstrumentation.COMPONENT}-${cmd.command}`, - { - kind: SpanKind.CLIENT, - attributes: { - [SEMATTRS_DB_SYSTEM]: DBSYSTEMVALUES_REDIS, - [SEMATTRS_DB_STATEMENT]: dbStatementSerializer( - cmd.command, - cmd.args - ), - }, - } - ); - - // Set attributes for not explicitly typed RedisPluginClientTypes - if (this.connection_options) { - span.setAttributes({ - [SEMATTRS_NET_PEER_NAME]: this.connection_options.host, - [SEMATTRS_NET_PEER_PORT]: this.connection_options.port, - }); - } - if (this.address) { - span.setAttribute( - SEMATTRS_DB_CONNECTION_STRING, - `redis://${this.address}` - ); - } - - const originalCallback = arguments[0].callback; - if (originalCallback) { - const originalContext = context.active(); - (arguments[0] as RedisCommand).callback = function callback( - this: unknown, - err: Error | null, - reply: T - ) { - if (config?.responseHook) { - const responseHook = config.responseHook; - safeExecuteInTheMiddle( - () => { - responseHook(span, cmd.command, cmd.args, reply); - }, - err => { - if (err) { - instrumentation._diag.error( - 'Error executing responseHook', - err - ); - } - }, - true - ); - } + override init() {} - endSpan(span, err); - return context.with( - originalContext, - originalCallback, - this, - ...arguments - ); - }; - } - try { - // Span will be ended in callback - return original.apply(this, arguments); - } catch (rethrow: any) { - endSpan(span, rethrow); - throw rethrow; // rethrow after ending span - } - }; - }; + override setTracerProvider(tracerProvider: TracerProvider) { + super.setTracerProvider(tracerProvider); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.setTracerProvider(tracerProvider); + this.instrumentationV4.setTracerProvider(tracerProvider); } - private _getPatchCreateClient() { - return function createClient(original: Function) { - return getTracedCreateClient(original); - }; + override enable() { + super.enable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.enable(); + this.instrumentationV4.enable(); } - private _getPatchCreateStream() { - return function createReadStream(original: Function) { - return getTracedCreateStreamTrace(original); - }; + override disable() { + super.disable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.disable(); + this.instrumentationV4.disable(); } } diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/types.ts b/plugins/node/opentelemetry-instrumentation-redis/src/types.ts index 32276281b4..e15141a22e 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/src/types.ts +++ b/plugins/node/opentelemetry-instrumentation-redis/src/types.ts @@ -17,16 +17,6 @@ import { Span } from '@opentelemetry/api'; import { InstrumentationConfig } from '@opentelemetry/instrumentation'; -// exported from -// https://github.com/redis/node-redis/blob/v3.1.2/lib/command.js -export interface RedisCommand { - command: string; - args: string[]; - buffer_args: boolean; - callback: (err: Error | null, reply: unknown) => void; - call_on_write: boolean; -} - /** * Function that can be used to serialize db.statement tag * @param cmdName - The name of the command (eg. set, get, mset) @@ -35,8 +25,8 @@ export interface RedisCommand { * @returns serialized string that will be used as the db.statement attribute. */ export type DbStatementSerializer = ( - cmdName: RedisCommand['command'], - cmdArgs: RedisCommand['args'] + cmdName: string, + cmdArgs: Array ) => string; /** @@ -51,8 +41,8 @@ export type DbStatementSerializer = ( export interface RedisResponseCustomAttributeFunction { ( span: Span, - cmdName: RedisCommand['command'], - cmdArgs: RedisCommand['args'], + cmdName: string, + cmdArgs: Array, response: unknown ): void; } diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/instrumentation.ts b/plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/instrumentation.ts new file mode 100644 index 0000000000..d9b37c4dd3 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/instrumentation.ts @@ -0,0 +1,215 @@ +/* + * 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 { + isWrapped, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { + endSpan, + getTracedCreateClient, + getTracedCreateStreamTrace, +} from './utils'; +import { RedisInstrumentationConfig } from '../types'; +/** @knipignore */ +import { PACKAGE_NAME, PACKAGE_VERSION } from '../version'; +import { RedisCommand, RedisPluginClientTypes } from './internal-types'; +import { SpanKind, context, trace } from '@opentelemetry/api'; +import { + DBSYSTEMVALUES_REDIS, + SEMATTRS_DB_CONNECTION_STRING, + SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_SYSTEM, + SEMATTRS_NET_PEER_NAME, + SEMATTRS_NET_PEER_PORT, +} from '@opentelemetry/semantic-conventions'; +import { defaultDbStatementSerializer } from '@opentelemetry/redis-common'; + +export class RedisInstrumentationV2_V3 extends InstrumentationBase { + static readonly COMPONENT = 'redis'; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + } + + protected init() { + return [ + new InstrumentationNodeModuleDefinition( + 'redis', + ['>=2.6.0 <4'], + moduleExports => { + if ( + isWrapped( + moduleExports.RedisClient.prototype['internal_send_command'] + ) + ) { + this._unwrap( + moduleExports.RedisClient.prototype, + 'internal_send_command' + ); + } + this._wrap( + moduleExports.RedisClient.prototype, + 'internal_send_command', + this._getPatchInternalSendCommand() + ); + + if (isWrapped(moduleExports.RedisClient.prototype['create_stream'])) { + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + } + this._wrap( + moduleExports.RedisClient.prototype, + 'create_stream', + this._getPatchCreateStream() + ); + + if (isWrapped(moduleExports.createClient)) { + this._unwrap(moduleExports, 'createClient'); + } + this._wrap( + moduleExports, + 'createClient', + this._getPatchCreateClient() + ); + return moduleExports; + }, + moduleExports => { + if (moduleExports === undefined) return; + this._unwrap( + moduleExports.RedisClient.prototype, + 'internal_send_command' + ); + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + this._unwrap(moduleExports, 'createClient'); + } + ), + ]; + } + + /** + * Patch internal_send_command(...) to trace requests + */ + private _getPatchInternalSendCommand() { + const instrumentation = this; + return function internal_send_command(original: Function) { + return function internal_send_command_trace( + this: RedisPluginClientTypes, + cmd?: RedisCommand + ) { + // Versions of redis (2.4+) use a single options object + // instead of named arguments + if (arguments.length !== 1 || typeof cmd !== 'object') { + // We don't know how to trace this call, so don't start/stop a span + return original.apply(this, arguments); + } + + const config = instrumentation.getConfig(); + + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + + const dbStatementSerializer = + config?.dbStatementSerializer || defaultDbStatementSerializer; + const span = instrumentation.tracer.startSpan( + `${RedisInstrumentationV2_V3.COMPONENT}-${cmd.command}`, + { + kind: SpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: DBSYSTEMVALUES_REDIS, + [SEMATTRS_DB_STATEMENT]: dbStatementSerializer( + cmd.command, + cmd.args + ), + }, + } + ); + + // Set attributes for not explicitly typed RedisPluginClientTypes + if (this.connection_options) { + span.setAttributes({ + [SEMATTRS_NET_PEER_NAME]: this.connection_options.host, + [SEMATTRS_NET_PEER_PORT]: this.connection_options.port, + }); + } + if (this.address) { + span.setAttribute( + SEMATTRS_DB_CONNECTION_STRING, + `redis://${this.address}` + ); + } + + const originalCallback = arguments[0].callback; + if (originalCallback) { + const originalContext = context.active(); + (arguments[0] as RedisCommand).callback = function callback( + this: unknown, + err: Error | null, + reply: T + ) { + if (config?.responseHook) { + const responseHook = config.responseHook; + safeExecuteInTheMiddle( + () => { + responseHook(span, cmd.command, cmd.args, reply); + }, + err => { + if (err) { + instrumentation._diag.error( + 'Error executing responseHook', + err + ); + } + }, + true + ); + } + + endSpan(span, err); + return context.with( + originalContext, + originalCallback, + this, + ...arguments + ); + }; + } + try { + // Span will be ended in callback + return original.apply(this, arguments); + } catch (rethrow: any) { + endSpan(span, rethrow); + throw rethrow; // rethrow after ending span + } + }; + }; + } + + private _getPatchCreateClient() { + return function createClient(original: Function) { + return getTracedCreateClient(original); + }; + } + + private _getPatchCreateStream() { + return function createReadStream(original: Function) { + return getTracedCreateStreamTrace(original); + }; + } +} diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/internal-types.ts b/plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/internal-types.ts similarity index 73% rename from plugins/node/opentelemetry-instrumentation-redis/src/internal-types.ts rename to plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/internal-types.ts index 545a1927e4..25c2fc6b34 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/src/internal-types.ts +++ b/plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/internal-types.ts @@ -21,3 +21,13 @@ export interface RedisPluginClientTypes { address?: string; } + +// exported from +// https://github.com/redis/node-redis/blob/v3.1.2/lib/command.js +export interface RedisCommand { + command: string; + args: string[]; + buffer_args: boolean; + callback: (err: Error | null, reply: unknown) => void; + call_on_write: boolean; +} diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/utils.ts b/plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/utils.ts similarity index 100% rename from plugins/node/opentelemetry-instrumentation-redis/src/utils.ts rename to plugins/node/opentelemetry-instrumentation-redis/src/v2-v3/utils.ts diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/v4/instrumentation.ts b/plugins/node/opentelemetry-instrumentation-redis/src/v4/instrumentation.ts new file mode 100644 index 0000000000..5ef319dfa6 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis/src/v4/instrumentation.ts @@ -0,0 +1,490 @@ +/* + * 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 { + trace, + context, + SpanKind, + Span, + SpanStatusCode, +} from '@opentelemetry/api'; +import { + isWrapped, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import { getClientAttributes } from './utils'; +import { defaultDbStatementSerializer } from '@opentelemetry/redis-common'; +import { RedisInstrumentationConfig } from '../types'; +/** @knipignore */ +import { PACKAGE_NAME, PACKAGE_VERSION } from '../version'; +import { SEMATTRS_DB_STATEMENT } from '@opentelemetry/semantic-conventions'; +import type { MultiErrorReply } from './internal-types'; + +const OTEL_OPEN_SPANS = Symbol( + 'opentelemetry.instrumentation.redis.open_spans' +); +const MULTI_COMMAND_OPTIONS = Symbol( + 'opentelemetry.instrumentation.redis.multi_command_options' +); + +interface MutliCommandInfo { + span: Span; + commandName: string; + commandArgs: Array; +} + +const DEFAULT_CONFIG: RedisInstrumentationConfig = { + requireParentSpan: false, +}; + +export class RedisInstrumentationV4 extends InstrumentationBase { + static readonly COMPONENT = 'redis'; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, { ...DEFAULT_CONFIG, ...config }); + } + + override setConfig(config: RedisInstrumentationConfig = {}) { + super.setConfig({ ...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 + if (isWrapped(moduleExports?.[functionToPatch])) { + this._unwrap(moduleExports, functionToPatch); + } + this._wrap( + moduleExports, + functionToPatch, + this._getPatchExtendWithCommands(transformCommandArguments) + ); + + return moduleExports; + }, + (moduleExports: any) => { + 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) => { + 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) => { + 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) => { + const redisClientPrototype = moduleExports?.default?.prototype; + + // In some @redis/client versions 'multi' is a method. In later + // versions, as of https://github.com/redis/node-redis/pull/2324, + // 'MULTI' is a method and 'multi' is a property defined in the + // constructor that points to 'MULTI', and therefore it will not + // be defined on the prototype. + if (redisClientPrototype?.multi) { + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + this._wrap( + redisClientPrototype, + 'multi', + this._getPatchRedisClientMulti() + ); + } + if (redisClientPrototype?.MULTI) { + 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() + ); + + this._wrap( + redisClientPrototype, + 'connect', + this._getPatchedClientConnect() + ); + + return moduleExports; + }, + (moduleExports: any) => { + const redisClientPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + 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) => { + return moduleExports; + }, + () => {}, + [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; + } + + return execRes + .then((redisRes: unknown[]) => { + const openSpans = this[OTEL_OPEN_SPANS]; + plugin._endSpansWithRedisReplies(openSpans, redisRes); + return redisRes; + }) + .catch((err: Error) => { + const openSpans = this[OTEL_OPEN_SPANS]; + if (!openSpans) { + plugin._diag.error( + 'cannot find open spans to end for redis multi command' + ); + } else { + const replies = + err.constructor.name === 'MultiErrorReply' + ? (err as MultiErrorReply).replies + : new Array(openSpans.length).fill(err); + plugin._endSpansWithRedisReplies(openSpans, replies); + } + return Promise.reject(err); + }); + }; + }; + } + + 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 _getPatchedClientConnect() { + const plugin = this; + return function connectWrapper(original: Function) { + return function patchedConnect(this: any): Promise { + const options = this.options; + + const attributes = getClientAttributes(plugin._diag, options); + + const span = plugin.tracer.startSpan( + `${RedisInstrumentationV4.COMPONENT}-connect`, + { + kind: SpanKind.CLIENT, + attributes, + } + ); + + const res = context.with(trace.setSpan(context.active(), span), () => { + return original.apply(this); + }); + + return res + .then((result: unknown) => { + span.end(); + return result; + }) + .catch((error: Error) => { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.end(); + return Promise.reject(error); + }); + }; + }; + } + + private _traceClientCommand( + origFunction: Function, + origThis: any, + origArguments: IArguments, + redisCommandArguments: Array + ) { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (hasNoParentSpan && this.getConfig().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.getConfig().dbStatementSerializer || defaultDbStatementSerializer; + + const attributes = getClientAttributes(this._diag, clientOptions); + + try { + const dbStatement = dbStatementSerializer(commandName, commandArgs); + if (dbStatement != null) { + attributes[SEMATTRS_DB_STATEMENT] = dbStatement; + } + } catch (e) { + this._diag.error('dbStatementSerializer throw an exception', e, { + commandName, + }); + } + + const span = this.tracer.startSpan( + `${RedisInstrumentationV4.COMPONENT}-${commandName}`, + { + kind: SpanKind.CLIENT, + attributes, + } + ); + + const res = context.with(trace.setSpan(context.active(), span), () => { + return 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 _endSpansWithRedisReplies( + openSpans: Array, + replies: unknown[] + ) { + if (!openSpans) { + return this._diag.error( + 'cannot find open spans to end for redis multi command' + ); + } + if (replies.length !== openSpans.length) { + return this._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 = replies[i]; + const [res, err] = + currCommandRes instanceof Error + ? [null, currCommandRes] + : [currCommandRes, undefined]; + this._endSpanWithResponse(span, commandName, commandArgs, res, err); + } + } + + private _endSpanWithResponse( + span: Span, + commandName: string, + commandArgs: Array, + response: unknown, + error: Error | undefined + ) { + const { responseHook } = this.getConfig(); + if (!error && responseHook) { + try { + 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/src/v4/internal-types.ts b/plugins/node/opentelemetry-instrumentation-redis/src/v4/internal-types.ts new file mode 100644 index 0000000000..cf03d290d1 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis/src/v4/internal-types.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. + */ + +// Error class introduced in redis@4.6.12. +// https://github.com/redis/node-redis/blob/redis@4.6.12/packages/client/lib/errors.ts#L69-L84 +export interface MultiErrorReply extends Error { + replies: unknown[]; + errorIndexes: Array; +} diff --git a/plugins/node/opentelemetry-instrumentation-redis/src/v4/utils.ts b/plugins/node/opentelemetry-instrumentation-redis/src/v4/utils.ts new file mode 100644 index 0000000000..b7cc79628d --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis/src/v4/utils.ts @@ -0,0 +1,63 @@ +/* + * 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 { Attributes, DiagLogger } from '@opentelemetry/api'; +import { + SEMATTRS_DB_SYSTEM, + SEMATTRS_DB_CONNECTION_STRING, + SEMATTRS_NET_PEER_NAME, + SEMATTRS_NET_PEER_PORT, + DBSYSTEMVALUES_REDIS, +} from '@opentelemetry/semantic-conventions'; + +export function getClientAttributes( + diag: DiagLogger, + options: any +): Attributes { + return { + [SEMATTRS_DB_SYSTEM]: DBSYSTEMVALUES_REDIS, + [SEMATTRS_NET_PEER_NAME]: options?.socket?.host, + [SEMATTRS_NET_PEER_PORT]: options?.socket?.port, + [SEMATTRS_DB_CONNECTION_STRING]: + removeCredentialsFromDBConnectionStringAttribute(diag, options?.url), + }; +} + +/** + * removeCredentialsFromDBConnectionStringAttribute removes basic auth from url and user_pwd from query string + * + * Examples: + * redis://user:pass@localhost:6379/mydb => redis://localhost:6379/mydb + * redis://localhost:6379?db=mydb&user_pwd=pass => redis://localhost:6379?db=mydb + */ +function removeCredentialsFromDBConnectionStringAttribute( + diag: DiagLogger, + url?: unknown +): string | undefined { + if (typeof url !== 'string' || !url) { + return; + } + + try { + const u = new URL(url); + u.searchParams.delete('user_pwd'); + u.username = ''; + u.password = ''; + return u.href; + } catch (err) { + diag.error('failed to sanitize redis connection url', err); + } + return; +} diff --git a/plugins/node/opentelemetry-instrumentation-redis/test/redis.test.ts b/plugins/node/opentelemetry-instrumentation-redis/test/v2-v3/redis.test.ts similarity index 69% rename from plugins/node/opentelemetry-instrumentation-redis/test/redis.test.ts rename to plugins/node/opentelemetry-instrumentation-redis/test/v2-v3/redis.test.ts index 39973aadfe..751f237858 100644 --- a/plugins/node/opentelemetry-instrumentation-redis/test/redis.test.ts +++ b/plugins/node/opentelemetry-instrumentation-redis/test/v2-v3/redis.test.ts @@ -21,16 +21,15 @@ import { SpanStatus, trace, Span, + ROOT_CONTEXT, } from '@opentelemetry/api'; -import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import * as testUtils from '@opentelemetry/contrib-test-utils'; import { - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; + getTestSpans, + registerInstrumentationTesting, +} from '@opentelemetry/contrib-test-utils'; import * as assert from 'assert'; -import { RedisInstrumentation } from '../src'; +import { RedisInstrumentation } from '../../src'; import { DBSYSTEMVALUES_REDIS, SEMATTRS_DB_CONNECTION_STRING, @@ -40,14 +39,12 @@ import { SEMATTRS_NET_PEER_PORT, } from '@opentelemetry/semantic-conventions'; -const instrumentation = new RedisInstrumentation(); -instrumentation.enable(); -instrumentation.disable(); +const instrumentation = registerInstrumentationTesting( + new RedisInstrumentation() +); import * as redisTypes from 'redis'; -import { RedisResponseCustomAttributeFunction } from '../src/types'; - -const memoryExporter = new InMemorySpanExporter(); +import { RedisResponseCustomAttributeFunction } from '../../src/types'; const CONFIG = { host: process.env.OPENTELEMETRY_REDIS_HOST || 'localhost', @@ -68,26 +65,10 @@ const unsetStatus: SpanStatus = { }; describe('redis@2.x', () => { - const provider = new NodeTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(memoryExporter)], - }); - const tracer = provider.getTracer('external'); let redis: typeof redisTypes; const shouldTestLocal = process.env.RUN_REDIS_TESTS_LOCAL; const shouldTest = process.env.RUN_REDIS_TESTS || shouldTestLocal; - - let contextManager: AsyncLocalStorageContextManager; - beforeEach(() => { - contextManager = new AsyncLocalStorageContextManager().enable(); - context.setGlobalContextManager(contextManager); - // set the default tracer provider before each test - // specific ones can override it to assert certain things - instrumentation.setTracerProvider(provider); - }); - - afterEach(() => { - context.disable(); - }); + const tracer = trace.getTracer('external'); before(function () { // needs to be "function" to have MochaContext "this" context @@ -103,8 +84,6 @@ describe('redis@2.x', () => { } redis = require('redis'); - instrumentation.setTracerProvider(provider); - instrumentation.enable(); }); after(() => { @@ -179,7 +158,7 @@ describe('redis@2.x', () => { beforeEach(done => { client.set('test', 'data', () => { - memoryExporter.reset(); + testUtils.resetMemoryExporter(); done(); }); }); @@ -190,7 +169,7 @@ describe('redis@2.x', () => { afterEach(done => { client.del('hash', () => { - memoryExporter.reset(); + testUtils.resetMemoryExporter(); done(); }); }); @@ -206,9 +185,9 @@ describe('redis@2.x', () => { context.with(trace.setSpan(context.active(), span), () => { operation.method((err, _result) => { assert.ifError(err); - assert.strictEqual(memoryExporter.getFinishedSpans().length, 1); + assert.strictEqual(getTestSpans().length, 1); span.end(); - const endedSpans = memoryExporter.getFinishedSpans(); + const endedSpans = getTestSpans(); assert.strictEqual(endedSpans.length, 2); assert.strictEqual( endedSpans[0].name, @@ -239,40 +218,18 @@ describe('redis@2.x', () => { }); }); - describe('Removing instrumentation', () => { - before(() => { - instrumentation.disable(); - }); - - REDIS_OPERATIONS.forEach(operation => { - it(`should not create a child span for ${operation.description}`, done => { - const span = tracer.startSpan('test span'); - context.with(trace.setSpan(context.active(), span), () => { - operation.method((err, _) => { - assert.ifError(err); - assert.strictEqual(memoryExporter.getFinishedSpans().length, 0); - span.end(); - const endedSpans = memoryExporter.getFinishedSpans(); - assert.strictEqual(endedSpans.length, 1); - assert.strictEqual(endedSpans[0], span); - done(); - }); - }); - }); - }); - }); - describe('dbStatementSerializer config', () => { - const dbStatementSerializer = (cmdName: string, cmdArgs: string[]) => { + const dbStatementSerializer = ( + cmdName: string, + cmdArgs: Array + ) => { return Array.isArray(cmdArgs) && cmdArgs.length ? `${cmdName} ${cmdArgs.join(' ')}` : cmdName; }; - before(() => { - instrumentation.disable(); + beforeEach(() => { instrumentation.setConfig({ dbStatementSerializer }); - instrumentation.enable(); }); REDIS_OPERATIONS.forEach(operation => { @@ -282,7 +239,7 @@ describe('redis@2.x', () => { operation.method((err, _) => { assert.ifError(err); span.end(); - const endedSpans = memoryExporter.getFinishedSpans(); + const endedSpans = getTestSpans(); assert.strictEqual(endedSpans.length, 2); const expectedStatement = dbStatementSerializer( operation.command, @@ -306,23 +263,21 @@ describe('redis@2.x', () => { const responseHook: RedisResponseCustomAttributeFunction = ( span: Span, _cmdName: string, - _cmdArgs: string[], + _cmdArgs: Array, response: unknown ) => { span.setAttribute(dataFieldName, new String(response).toString()); }; - before(() => { - instrumentation.disable(); + beforeEach(() => { instrumentation.setConfig({ responseHook }); - instrumentation.enable(); }); REDIS_OPERATIONS.forEach(operation => { it(`should apply responseHook for operation ${operation.description}`, done => { operation.method((err, reply) => { assert.ifError(err); - const endedSpans = memoryExporter.getFinishedSpans(); + const endedSpans = getTestSpans(); assert.strictEqual( endedSpans[0].attributes[dataFieldName], new String(reply).toString() @@ -337,23 +292,21 @@ describe('redis@2.x', () => { const badResponseHook: RedisResponseCustomAttributeFunction = ( _span: Span, _cmdName: string, - _cmdArgs: string[], + _cmdArgs: Array, _response: unknown ) => { throw 'Some kind of error'; }; - before(() => { - instrumentation.disable(); + beforeEach(() => { instrumentation.setConfig({ responseHook: badResponseHook }); - instrumentation.enable(); }); REDIS_OPERATIONS.forEach(operation => { it(`should not fail because of responseHook error for operation ${operation.description}`, done => { operation.method((err, _reply) => { assert.ifError(err); - const endedSpans = memoryExporter.getFinishedSpans(); + const endedSpans = getTestSpans(); assert.strictEqual(endedSpans.length, 1); done(); }); @@ -363,19 +316,19 @@ describe('redis@2.x', () => { }); describe('requireParentSpan config', () => { - before(() => { - instrumentation.disable(); + beforeEach(() => { instrumentation.setConfig({ requireParentSpan: true }); - instrumentation.enable(); }); REDIS_OPERATIONS.forEach(operation => { it(`should not create span without parent span for operation ${operation.description}`, done => { - operation.method((err, _) => { - assert.ifError(err); - const endedSpans = memoryExporter.getFinishedSpans(); - assert.strictEqual(endedSpans.length, 0); - done(); + context.with(ROOT_CONTEXT, () => { + operation.method((err, _) => { + assert.ifError(err); + const endedSpans = getTestSpans(); + assert.strictEqual(endedSpans.length, 0); + done(); + }); }); }); @@ -384,7 +337,7 @@ describe('redis@2.x', () => { context.with(trace.setSpan(context.active(), span), () => { operation.method((err, _) => { assert.ifError(err); - const endedSpans = memoryExporter.getFinishedSpans(); + const endedSpans = getTestSpans(); assert.strictEqual(endedSpans.length, 1); done(); }); @@ -392,36 +345,5 @@ describe('redis@2.x', () => { }); }); }); - - describe('setTracerProvider', () => { - before(() => { - instrumentation.disable(); - instrumentation.setConfig({}); - instrumentation.enable(); - }); - - it('should use new tracer provider after setTracerProvider is called', done => { - const testSpecificMemoryExporter = new InMemorySpanExporter(); - const spanProcessor = new SimpleSpanProcessor( - testSpecificMemoryExporter - ); - const tracerProvider = new NodeTracerProvider({ - spanProcessors: [spanProcessor], - }); - - // key point of this test, setting new tracer provider and making sure - // new spans use it. - instrumentation.setTracerProvider(tracerProvider); - - client.set('test', 'value-with-new-tracer-provider', err => { - assert.ifError(err); - // assert that the span was exported by the new tracer provider - // which is using the test specific span processor - const spans = testSpecificMemoryExporter.getFinishedSpans(); - assert.strictEqual(spans.length, 1); - done(); - }); - }); - }); }); }); diff --git a/plugins/node/opentelemetry-instrumentation-redis/test/v4/redis.test.ts b/plugins/node/opentelemetry-instrumentation-redis/test/v4/redis.test.ts new file mode 100644 index 0000000000..b45171cb2e --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis/test/v4/redis.test.ts @@ -0,0 +1,620 @@ +/* + * 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, DiagLogLevel, ROOT_CONTEXT } from '@opentelemetry/api'; +import { + getTestSpans, + registerInstrumentationTesting, +} from '@opentelemetry/contrib-test-utils'; +import { RedisInstrumentation } from '../../src'; +import type { MultiErrorReply } from '../../src/v4/internal-types'; +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 type { RedisClientType } from '@redis/client'; +import { + Span, + SpanKind, + SpanStatusCode, + trace, + context, +} from '@opentelemetry/api'; +import { + SEMATTRS_DB_CONNECTION_STRING, + SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_SYSTEM, + SEMATTRS_EXCEPTION_MESSAGE, + SEMATTRS_NET_PEER_NAME, + SEMATTRS_NET_PEER_PORT, +} from '@opentelemetry/semantic-conventions'; +import { RedisResponseCustomAttributeFunction } from '../../src/types'; +import { hrTimeToMilliseconds, suppressTracing } 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: RedisClientType; + + beforeEach(async () => { + client = createClient({ + url: redisTestUrl, + }) as unknown as RedisClientType; + await context.with(suppressTracing(context.active()), async () => { + 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[SEMATTRS_DB_SYSTEM], 'redis'); + assert.strictEqual( + setSpan?.attributes[SEMATTRS_DB_STATEMENT], + 'SET key [1 other arguments]' + ); + assert.strictEqual( + setSpan?.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + setSpan?.attributes[SEMATTRS_NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + setSpan?.attributes[SEMATTRS_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[SEMATTRS_DB_SYSTEM], 'redis'); + assert.strictEqual(getSpan?.attributes[SEMATTRS_DB_STATEMENT], 'GET key'); + assert.strictEqual( + getSpan?.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + getSpan?.attributes[SEMATTRS_NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + getSpan?.attributes[SEMATTRS_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[SEMATTRS_DB_STATEMENT], + 'SET key [1 other arguments]' + ); + assert.strictEqual( + setSpan?.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + setSpan?.attributes[SEMATTRS_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?.[SEMATTRS_EXCEPTION_MESSAGE], + 'ERR value is not an integer or out of range' + ); + }); + }); + + describe('client connect', () => { + it('produces a span', async () => { + const newClient = createClient({ + url: redisTestUrl, + }) as unknown as RedisClientType; + + after(async () => { + await newClient.disconnect(); + }); + + await newClient.connect(); + + const [span] = getTestSpans(); + + assert.strictEqual(span.name, 'redis-connect'); + + assert.strictEqual(span.attributes[SEMATTRS_DB_SYSTEM], 'redis'); + assert.strictEqual( + span.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + span.attributes[SEMATTRS_NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + span.attributes[SEMATTRS_DB_CONNECTION_STRING], + redisTestUrl + ); + }); + + it('sets error status on connection failure', async () => { + const redisURL = `redis://${redisTestConfig.host}:${ + redisTestConfig.port + 1 + }`; + const newClient = createClient({ + url: redisURL, + }) as unknown as RedisClientType; + + await assert.rejects(newClient.connect()); + + const [span] = getTestSpans(); + + assert.strictEqual(span.name, 'redis-connect'); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + assert.strictEqual( + span.attributes[SEMATTRS_DB_CONNECTION_STRING], + redisURL + ); + }); + + it('omits basic auth from DB_CONNECTION_STRING span attribute', async () => { + const redisURL = `redis://myuser:mypassword@${redisTestConfig.host}:${ + redisTestConfig.port + 1 + }`; + const expectAttributeConnString = `redis://${redisTestConfig.host}:${ + redisTestConfig.port + 1 + }`; + const newClient = createClient({ + url: redisURL, + }) as unknown as RedisClientType; + + await assert.rejects(newClient.connect()); + + const [span] = getTestSpans(); + + assert.strictEqual(span.name, 'redis-connect'); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + assert.strictEqual( + span.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + span.attributes[SEMATTRS_DB_CONNECTION_STRING], + expectAttributeConnString + ); + }); + + it('omits user_pwd query parameter from DB_CONNECTION_STRING span attribute', async () => { + const redisURL = `redis://${redisTestConfig.host}:${ + redisTestConfig.port + 1 + }?db=mydb&user_pwd=mypassword`; + const expectAttributeConnString = `redis://${redisTestConfig.host}:${ + redisTestConfig.port + 1 + }?db=mydb`; + const newClient = createClient({ + url: redisURL, + }) as unknown as RedisClientType; + + await assert.rejects(newClient.connect()); + + const [span] = getTestSpans(); + + assert.strictEqual(span.name, 'redis-connect'); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + assert.strictEqual( + span.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + span.attributes[SEMATTRS_DB_CONNECTION_STRING], + expectAttributeConnString + ); + }); + + it('with empty string for client URL, there is no crash and no diag.error', async () => { + // Note: This messily leaves the diag logger set for other tests. + const diagErrors = [] as any; + diag.setLogger( + { + verbose() {}, + debug() {}, + info() {}, + warn() {}, + error(...args) { + diagErrors.push(args); + }, + }, + DiagLogLevel.WARN + ); + + const newClient = createClient({ url: '' }) as unknown as RedisClientType; + try { + await newClient.connect(); + } catch (_err) { + // Ignore. If the test Redis is not at the default port we expect this + // to error. + } + await newClient.disconnect(); + + const [span] = getTestSpans(); + assert.strictEqual(span.name, 'redis-connect'); + assert.strictEqual(diagErrors.length, 0, "no diag.error's"); + }); + }); + + 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[SEMATTRS_DB_STATEMENT], + 'SET key [1 other arguments]' + ); + assert.strictEqual( + multiSetSpan?.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + multiSetSpan?.attributes[SEMATTRS_NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + multiSetSpan?.attributes[SEMATTRS_DB_CONNECTION_STRING], + redisTestUrl + ); + + assert.ok(multiGetSpan); + assert.strictEqual(multiGetSpan.name, 'redis-GET'); + assert.strictEqual( + multiGetSpan.attributes[SEMATTRS_DB_STATEMENT], + 'GET another-key' + ); + assert.strictEqual( + multiGetSpan?.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + multiGetSpan?.attributes[SEMATTRS_NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + multiGetSpan?.attributes[SEMATTRS_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[SEMATTRS_DB_STATEMENT], + 'SET key [1 other arguments]' + ); + assert.strictEqual( + multiSetSpan?.attributes[SEMATTRS_NET_PEER_NAME], + redisTestConfig.host + ); + assert.strictEqual( + multiSetSpan?.attributes[SEMATTRS_NET_PEER_PORT], + redisTestConfig.port + ); + assert.strictEqual( + multiSetSpan?.attributes[SEMATTRS_DB_CONNECTION_STRING], + redisTestUrl + ); + }); + + it('multi command with error', async () => { + let replies; + try { + replies = await client.multi().set('key', 'value').incr('key').exec(); + } catch (err) { + // Starting in redis@4.6.12 `multi().exec()` will *throw* a + // MultiErrorReply, with `err.replies`, if any of the commands error. + replies = (err as MultiErrorReply).replies; + } + const [setReply, incrReply] = replies; + + 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('multi command that rejects', async () => { + const watchedKey = 'watched-key'; + await client.watch(watchedKey); + await client.set(watchedKey, 'a different value'); + try { + await client.multi().get(watchedKey).exec(); + assert.fail('expected WatchError to be thrown and caught in try/catch'); + } catch (error) { + assert.ok(error instanceof Error); + } + + // All the multi spans' status are set to ERROR. + const [_watchSpan, _setSpan, multiGetSpan] = getTestSpans(); + assert.strictEqual(multiGetSpan?.status.code, SpanStatusCode.ERROR); + assert.strictEqual( + multiGetSpan?.status.message, + 'One (or more) of the watched keys has been changed' + ); + }); + + 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[SEMATTRS_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(!(SEMATTRS_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 + context.with(ROOT_CONTEXT, 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 === 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/test/v4/utils.ts b/plugins/node/opentelemetry-instrumentation-redis/test/v4/utils.ts new file mode 100644 index 0000000000..cc0e0a6609 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-redis/test/v4/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;