diff --git a/plugins/node/opentelemetry-instrumentation-ioredis/README.md b/plugins/node/opentelemetry-instrumentation-ioredis/README.md index 9b3a538eda..1c8eaa27d0 100644 --- a/plugins/node/opentelemetry-instrumentation-ioredis/README.md +++ b/plugins/node/opentelemetry-instrumentation-ioredis/README.md @@ -25,9 +25,11 @@ npm install --save @opentelemetry/instrumentation-ioredis To load a specific instrumentation (**ioredis** in this case), specify it in the registerInstrumentations's configuration ```javascript -const { NodeTracerProvider } = require('@opentelemetry/node'); -const { IORedisInstrumentation } = require('@opentelemetry/instrumentation-ioredis'); -const { registerInstrumentations } = require('@opentelemetry/instrumentation'); +const { NodeTracerProvider } = require("@opentelemetry/node"); +const { + IORedisInstrumentation, +} = require("@opentelemetry/instrumentation-ioredis"); +const { registerInstrumentations } = require("@opentelemetry/instrumentation"); const provider = new NodeTracerProvider(); provider.register(); @@ -36,20 +38,21 @@ registerInstrumentations({ instrumentations: [ new IORedisInstrumentation({ // see under for available configuration - }) + }), ], -}) +}); ``` ### IORedis Instrumentation Options IORedis instrumentation has few options available to choose from. You can set the following: -| Options | Type | Description | -| ------- | ---- | ----------- | -| `dbStatementSerializer` | `DbStatementSerializer` | IORedis instrumentation will serialize db.statement using the specified function. | -| `responseHook` | `RedisResponseCustomAttributeFunction` | Function for adding custom attributes on db response | -| `requireParentSpan` | `boolean` | Require parent to create ioredis span, default when unset is true | +| Options | Type | Description | +| ----------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `dbStatementSerializer` | `DbStatementSerializer` | IORedis instrumentation will serialize db.statement using the specified function. | +| `requestHook` | `RedisRequestCustomAttributeFunction` (function) | Function for adding custom attributes on db request. Receives params: `span, { moduleVersion, cmdName, cmdArgs }` | +| `responseHook` | `RedisResponseCustomAttributeFunction` (function) | Function for adding custom attributes on db response | +| `requireParentSpan` | `boolean` | Require parent to create ioredis span, default when unset is true | #### Custom db.statement Serializer @@ -64,6 +67,30 @@ const { IORedisInstrumentation } = require('@opentelemetry/instrumentation-iored const ioredisInstrumentation = new IORedisInstrumentation({ dbStatementSerializer: function (cmdName, cmdArgs) { return cmdName; + }, +}); +``` + +#### Using `requestHook` + +Instrumentation user can configure a custom "hook" function which will be called on every request with the relevant span and request information. User can then set custom attributes on the span or run any instrumentation-extension logic per request. + +Here is a simple example that adds a span attribute of `ioredis` instrumented version on each request: + +```javascript +const { IORedisInstrumentation } = require('@opentelemetry/instrumentation-ioredis'); + +const ioredisInstrumentation = new IORedisInstrumentation({ +requestHook: function ( + span: Span, + requestInfo: IORedisRequestHookInformation + ) { + if (requestInfo.moduleVersion) { + span.setAttribute( + 'instrumented_library.version', + requestInfo.moduleVersion + ); + } } }); diff --git a/plugins/node/opentelemetry-instrumentation-ioredis/src/ioredis.ts b/plugins/node/opentelemetry-instrumentation-ioredis/src/ioredis.ts index 97f2e46b83..7c74454c55 100644 --- a/plugins/node/opentelemetry-instrumentation-ioredis/src/ioredis.ts +++ b/plugins/node/opentelemetry-instrumentation-ioredis/src/ioredis.ts @@ -47,7 +47,7 @@ export class IORedisInstrumentation extends InstrumentationBase< new InstrumentationNodeModuleDefinition( 'ioredis', ['>1 <5'], - moduleExports => { + (moduleExports, moduleVersion?: string) => { diag.debug('Applying patch for ioredis'); if (isWrapped(moduleExports.prototype.sendCommand)) { this._unwrap(moduleExports.prototype, 'sendCommand'); @@ -55,7 +55,7 @@ export class IORedisInstrumentation extends InstrumentationBase< this._wrap( moduleExports.prototype, 'sendCommand', - this._patchSendCommand() + this._patchSendCommand(moduleVersion) ); if (isWrapped(moduleExports.prototype.connect)) { this._unwrap(moduleExports.prototype, 'connect'); @@ -80,9 +80,14 @@ export class IORedisInstrumentation extends InstrumentationBase< /** * Patch send command internal to trace requests */ - private _patchSendCommand() { + private _patchSendCommand(moduleVersion?: string) { return (original: Function) => { - return traceSendCommand(this.tracer, original, this._config); + return traceSendCommand( + this.tracer, + original, + this._config, + moduleVersion + ); }; } diff --git a/plugins/node/opentelemetry-instrumentation-ioredis/src/types.ts b/plugins/node/opentelemetry-instrumentation-ioredis/src/types.ts index 8b0334313f..66f3137484 100644 --- a/plugins/node/opentelemetry-instrumentation-ioredis/src/types.ts +++ b/plugins/node/opentelemetry-instrumentation-ioredis/src/types.ts @@ -39,6 +39,16 @@ export type DbStatementSerializer = ( cmdArgs: IORedisCommand['args'] ) => string; +export interface IORedisRequestHookInformation { + moduleVersion?: string; + cmdName: IORedisCommand['name']; + cmdArgs: IORedisCommand['args']; +} + +export interface RedisRequestCustomAttributeFunction { + (span: Span, requestInfo: IORedisRequestHookInformation): void; +} + /** * 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 @@ -64,6 +74,9 @@ export interface IORedisInstrumentationConfig extends InstrumentationConfig { /** Custom serializer function for the db.statement tag */ dbStatementSerializer?: DbStatementSerializer; + /** Function for adding custom attributes on db request */ + requestHook?: RedisRequestCustomAttributeFunction; + /** Function for adding custom attributes on db response */ responseHook?: RedisResponseCustomAttributeFunction; diff --git a/plugins/node/opentelemetry-instrumentation-ioredis/src/utils.ts b/plugins/node/opentelemetry-instrumentation-ioredis/src/utils.ts index 5136dafce9..6f101601f6 100644 --- a/plugins/node/opentelemetry-instrumentation-ioredis/src/utils.ts +++ b/plugins/node/opentelemetry-instrumentation-ioredis/src/utils.ts @@ -82,7 +82,8 @@ const defaultDbStatementSerializer: DbStatementSerializer = ( export const traceSendCommand = ( tracer: Tracer, original: Function, - config?: IORedisInstrumentationConfig + config?: IORedisInstrumentationConfig, + moduleVersion?: string ) => { const dbStatementSerializer = config?.dbStatementSerializer || defaultDbStatementSerializer; @@ -107,6 +108,23 @@ export const traceSendCommand = ( }, }); + if (config?.requestHook) { + safeExecuteInTheMiddle( + () => + config?.requestHook!(span, { + moduleVersion, + cmdName: cmd.name, + cmdArgs: cmd.args, + }), + e => { + if (e) { + diag.error('ioredis instrumentation: request hook failed', e); + } + }, + true + ); + } + const { host, port } = this.options; span.setAttributes({ @@ -125,7 +143,7 @@ export const traceSendCommand = ( () => config?.responseHook?.(span, cmd.name, cmd.args, result), e => { if (e) { - diag.error('ioredis response hook failed', e); + diag.error('ioredis instrumentation: response hook failed', e); } }, true diff --git a/plugins/node/opentelemetry-instrumentation-ioredis/test/ioredis.test.ts b/plugins/node/opentelemetry-instrumentation-ioredis/test/ioredis.test.ts index 0b8c4c4cdb..55bb8e360b 100644 --- a/plugins/node/opentelemetry-instrumentation-ioredis/test/ioredis.test.ts +++ b/plugins/node/opentelemetry-instrumentation-ioredis/test/ioredis.test.ts @@ -37,6 +37,7 @@ import { IORedisInstrumentation } from '../src'; import { IORedisInstrumentationConfig, DbStatementSerializer, + IORedisRequestHookInformation, } from '../src/types'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; @@ -226,6 +227,13 @@ describe('ioredis', () => { }); describe('Instrumenting query operations', () => { + before(() => { + instrumentation.disable(); + instrumentation = new IORedisInstrumentation(); + instrumentation.setTracerProvider(provider); + require('ioredis'); + }); + IOREDIS_CALLBACK_OPERATIONS.forEach(command => { it(`should create a child span for cb style ${command.description}`, done => { const attributes = { @@ -762,7 +770,74 @@ describe('ioredis', () => { }); }); - describe('Instrumenting with a custom responseHook', () => { + describe('Instrumenting with a custom hooks', () => { + it('should call requestHook when set in config', async () => { + instrumentation.disable(); + const config: IORedisInstrumentationConfig = { + requestHook: ( + span: Span, + requestInfo: IORedisRequestHookInformation + ) => { + assert.ok( + /\d{1,4}\.\d{1,4}\.\d{1,5}.*/.test( + requestInfo.moduleVersion as string + ) + ); + assert.strictEqual(requestInfo.cmdName, 'incr'); + assert.deepStrictEqual(requestInfo.cmdArgs, ['request-hook-test']); + + span.setAttribute( + 'attribute key from request hook', + 'custom value from request hook' + ); + }, + }; + instrumentation = new IORedisInstrumentation(config); + instrumentation.setTracerProvider(provider); + require('ioredis'); + + const span = provider.getTracer('ioredis-test').startSpan('test span'); + await context.with(setSpan(context.active(), span), async () => { + await client.incr('request-hook-test'); + const endedSpans = memoryExporter.getFinishedSpans(); + assert.strictEqual(endedSpans.length, 1); + assert.strictEqual( + endedSpans[0].attributes['attribute key from request hook'], + 'custom value from request hook' + ); + }); + }); + + it('should ignore requestHook which throws exception', async () => { + instrumentation.disable(); + const config: IORedisInstrumentationConfig = { + requestHook: ( + span: Span, + _requestInfo: IORedisRequestHookInformation + ) => { + span.setAttribute( + 'attribute key BEFORE exception', + 'this attribute is added to span BEFORE exception is thrown thus we can expect it' + ); + throw Error('error thrown in requestHook'); + }, + }; + instrumentation = new IORedisInstrumentation(config); + instrumentation.setTracerProvider(provider); + require('ioredis'); + + const span = provider.getTracer('ioredis-test').startSpan('test span'); + await context.with(setSpan(context.active(), span), async () => { + await client.incr('request-hook-throw-test'); + const endedSpans = memoryExporter.getFinishedSpans(); + assert.strictEqual(endedSpans.length, 1); + assert.strictEqual( + endedSpans[0].attributes['attribute key BEFORE exception'], + 'this attribute is added to span BEFORE exception is thrown thus we can expect it' + ); + }); + }); + it('should call responseHook when set in config', async () => { instrumentation.disable(); const config: IORedisInstrumentationConfig = { @@ -772,13 +847,17 @@ describe('ioredis', () => { _cmdArgs: Array, response: unknown ) => { - assert.strictEqual(cmdName, 'incr'); - // the command is 'incr' on a key which does not exist, thus it increase 0 by 1 and respond 1 - assert.strictEqual(response, 1); - span.setAttribute( - 'attribute key from hook', - 'custom value from hook' - ); + try { + assert.strictEqual(cmdName, 'incr'); + // the command is 'incr' on a key which does not exist, thus it increase 0 by 1 and respond 1 + assert.strictEqual(response, 1); + span.setAttribute( + 'attribute key from hook', + 'custom value from hook' + ); + } catch (err) { + console.log(err); + } }, }; instrumentation = new IORedisInstrumentation(config); @@ -787,7 +866,7 @@ describe('ioredis', () => { const span = provider.getTracer('ioredis-test').startSpan('test span'); await context.with(setSpan(context.active(), span), async () => { - await client.incr('new-key'); + await client.incr('response-hook-test'); const endedSpans = memoryExporter.getFinishedSpans(); assert.strictEqual(endedSpans.length, 1); assert.strictEqual( @@ -815,7 +894,7 @@ describe('ioredis', () => { const span = provider.getTracer('ioredis-test').startSpan('test span'); await context.with(setSpan(context.active(), span), async () => { - await client.incr('some-key'); + await client.incr('response-hook-throw-test'); const endedSpans = memoryExporter.getFinishedSpans(); // hook throw exception, but span should not be affected