From 2ba3380bbdb4ad846885e5931ac0105b8361073c Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Wed, 3 Sep 2025 15:03:48 +0300 Subject: [PATCH 1/5] feat(instrumentation-ioredis): add Redis Cluster instrumentation support --- .../src/instrumentation.ts | 191 +++++++++++++++++- .../src/internal-types.ts | 3 +- packages/instrumentation-ioredis/src/types.ts | 3 + packages/instrumentation-ioredis/src/utils.ts | 18 ++ 4 files changed, 212 insertions(+), 3 deletions(-) diff --git a/packages/instrumentation-ioredis/src/instrumentation.ts b/packages/instrumentation-ioredis/src/instrumentation.ts index daecaaa63b..e3bd6d1c50 100644 --- a/packages/instrumentation-ioredis/src/instrumentation.ts +++ b/packages/instrumentation-ioredis/src/instrumentation.ts @@ -21,7 +21,7 @@ import { isWrapped, } from '@opentelemetry/instrumentation'; import { IORedisInstrumentationConfig } from './types'; -import { IORedisCommand, RedisInterface } from './internal-types'; +import { ClusterInterface, IORedisCommand, RedisInterface } from './internal-types'; import { DBSYSTEMVALUES_REDIS, SEMATTRS_DB_CONNECTION_STRING, @@ -31,7 +31,7 @@ import { SEMATTRS_NET_PEER_PORT, } from '@opentelemetry/semantic-conventions'; import { safeExecuteInTheMiddle } from '@opentelemetry/instrumentation'; -import { endSpan } from './utils'; +import { endSpan, parseStartupNodes } from './utils'; import { defaultDbStatementSerializer } from '@opentelemetry/redis-common'; /** @knipignore */ import { PACKAGE_NAME, PACKAGE_VERSION } from './version'; @@ -59,6 +59,7 @@ export class IORedisInstrumentation extends InstrumentationBase { @@ -83,8 +109,18 @@ export class IORedisInstrumentation extends InstrumentationBase { + return this._traceClusterSendCommand(original, moduleVersion); + }; + } + + private _patchClusterConnect() { + return (original: Function) => { + return this._traceClusterConnection(original); + }; + } + private _traceSendCommand(original: Function, moduleVersion?: string) { const instrumentation = this; return function (this: RedisInterface, cmd?: IORedisCommand) { @@ -223,4 +271,143 @@ export class IORedisInstrumentation extends InstrumentationBase `${node.options.host}:${node.options.port}`); + + // Create cluster-level parent span + const span = instrumentation.tracer.startSpan(`cluster.${cmd.name}`, { + kind: SpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: DBSYSTEMVALUES_REDIS, + [SEMATTRS_DB_STATEMENT]: dbStatementSerializer(cmd.name, cmd.args), + 'db.redis.cluster.startup_nodes': startupNodes, + 'db.redis.cluster.nodes': nodes, + 'db.redis.is_cluster': true, + }, + }); + + // Execute request hook if configured + const { requestHook } = config; + if (requestHook) { + safeExecuteInTheMiddle( + () => + requestHook(span, { + moduleVersion, + cmdName: cmd.name, + cmdArgs: cmd.args, + }), + e => { + if (e) { + diag.error('ioredis cluster instrumentation: request hook failed', e); + } + }, + true + ); + } + + // Execute the original command with the cluster span as active context + return context.with(trace.setSpan(context.active(), span), () => { + try { + const result = original.apply(this, arguments); + + // Patch resolve/reject to end the cluster span + const origResolve = cmd.resolve; + const origReject = cmd.reject; + + cmd.resolve = function (result: any) { + safeExecuteInTheMiddle( + () => config.responseHook?.(span, cmd.name, cmd.args, result), + e => { + if (e) { + diag.error('ioredis cluster instrumentation: response hook failed', e); + } + }, + true + ); + endSpan(span, null); + origResolve(result); + }; + + cmd.reject = function (err: Error) { + endSpan(span, err); + origReject(err); + }; + + return result; + } catch (error: any) { + endSpan(span, error); + throw error; + } + }); + }; + } + + private _traceClusterConnection(original: Function) { + const instrumentation = this; + return function (this: ClusterInterface) { + const config = instrumentation.getConfig(); + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + + const startupNodes = 'startupNodes' in this ? parseStartupNodes(this['startupNodes']) : []; + const nodes = this.nodes().map(node => `${node.options.host}:${node.options.port}`); + + const span = instrumentation.tracer.startSpan('cluster.connect', { + kind: SpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: DBSYSTEMVALUES_REDIS, + [SEMATTRS_DB_STATEMENT]: 'connect', + 'db.redis.cluster.startup_nodes': startupNodes, + 'db.redis.cluster.nodes': nodes, + 'db.redis.is_cluster': true, + }, + }); + + try { + const result = original.apply(this, arguments); + + // Handle promise-based connect + if (result && typeof result.then === 'function') { + return result.then( + (value: any) => { + endSpan(span, null); + return value; + }, + (error: Error) => { + endSpan(span, error); + throw error; + } + ); + } + + endSpan(span, null); + return result; + } catch (error: any) { + endSpan(span, error); + throw error; + } + }; + } } diff --git a/packages/instrumentation-ioredis/src/internal-types.ts b/packages/instrumentation-ioredis/src/internal-types.ts index b399a1eb79..9192ca597e 100644 --- a/packages/instrumentation-ioredis/src/internal-types.ts +++ b/packages/instrumentation-ioredis/src/internal-types.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { Command, Redis } from 'ioredis'; +import type { Cluster, Command, Redis } from 'ioredis'; import type * as LegacyIORedis from 'ioredis4'; interface LegacyIORedisCommand { @@ -27,3 +27,4 @@ interface LegacyIORedisCommand { export type IORedisCommand = Command | LegacyIORedisCommand; export type RedisInterface = Redis | LegacyIORedis.Redis; +export type ClusterInterface = Cluster | LegacyIORedis.Cluster; diff --git a/packages/instrumentation-ioredis/src/types.ts b/packages/instrumentation-ioredis/src/types.ts index bdd5c5b435..6f64750513 100644 --- a/packages/instrumentation-ioredis/src/types.ts +++ b/packages/instrumentation-ioredis/src/types.ts @@ -69,4 +69,7 @@ export interface IORedisInstrumentationConfig extends InstrumentationConfig { /** Require parent to create ioredis span, default when unset is true */ requireParentSpan?: boolean; + + /** Whether to capture `Cluster` commands */ + instrumentCluster?: boolean; } diff --git a/packages/instrumentation-ioredis/src/utils.ts b/packages/instrumentation-ioredis/src/utils.ts index 6250284342..232cc2a65a 100644 --- a/packages/instrumentation-ioredis/src/utils.ts +++ b/packages/instrumentation-ioredis/src/utils.ts @@ -29,3 +29,21 @@ export const endSpan = ( } span.end(); }; + +export const parseStartupNodes = ( + startupNodes?: Array +): Array => { + if (!Array.isArray(startupNodes)) { + return []; + } + + return startupNodes.map((node) => { + if (typeof node === "string") { + return node; + } else if (typeof node === "number") { + return `localhost:${node}`; + } else { + return `${node.host}:${node.port}`; + } + }); +}; From 89fc571b0266f7f431c7316478c31252c416f115 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Fri, 5 Sep 2025 10:18:01 +0300 Subject: [PATCH 2/5] test(instrumentation-ioredis): add Redis Cluster instrumentation tests --- packages/instrumentation-ioredis/package.json | 4 +- .../test/ioredis.test.ts | 478 ++++++++++++++++++ test/docker-compose.yaml | 13 + 3 files changed, 493 insertions(+), 2 deletions(-) diff --git a/packages/instrumentation-ioredis/package.json b/packages/instrumentation-ioredis/package.json index 0cfe63e1eb..5eb6547ac5 100644 --- a/packages/instrumentation-ioredis/package.json +++ b/packages/instrumentation-ioredis/package.json @@ -23,8 +23,8 @@ "test:with-services-env": "cross-env NODE_OPTIONS='-r dotenv/config' DOTENV_CONFIG_PATH=../../test/test-services.env npm test", "test-all-versions": "tav", "test-all-versions:with-services-env": "cross-env NODE_OPTIONS='-r dotenv/config' DOTENV_CONFIG_PATH=../../test/test-services.env npm run test-all-versions", - "test-services:start": "cd ../.. && npm run test-services:start redis", - "test-services:stop": "cd ../.. && npm run test-services:stop redis", + "test-services:start": "cd ../.. && npm run test-services:start redis redis-cluster", + "test-services:stop": "cd ../.. && npm run test-services:stop redis redis-cluster", "version:update": "node ../../scripts/version-update.js" }, "keywords": [ diff --git a/packages/instrumentation-ioredis/test/ioredis.test.ts b/packages/instrumentation-ioredis/test/ioredis.test.ts index 39472b858f..9eb8c6cd64 100644 --- a/packages/instrumentation-ioredis/test/ioredis.test.ts +++ b/packages/instrumentation-ioredis/test/ioredis.test.ts @@ -50,6 +50,7 @@ import { SEMATTRS_NET_PEER_NAME, SEMATTRS_NET_PEER_PORT, } from '@opentelemetry/semantic-conventions'; +import { parseStartupNodes } from '../src/utils'; const memoryExporter = new InMemorySpanExporter(); @@ -1016,4 +1017,481 @@ describe('ioredis', () => { }, }); }); + + describe('ioredis cluster', () => { + const REDIS_CLUSTER_HOST = 'localhost'; + const REDIS_CLUSTER_PORTS = [3000, 3001, 3002, 3003, 3004, 3005]; + const REDIS_STARTUP_NODES = REDIS_CLUSTER_PORTS.map( + port => `${REDIS_CLUSTER_HOST}:${port}` + ); + const REDIS_CLUSTER_NODES = REDIS_CLUSTER_PORTS.map( + port => `127.0.0.1:${port}` + ); + + const CLUSTER_DEFAULT_ATTRIBUTES = { + [SEMATTRS_DB_SYSTEM]: DBSYSTEMVALUES_REDIS, + ['db.redis.cluster.nodes']: REDIS_CLUSTER_NODES, + ['db.redis.cluster.startup_nodes']: REDIS_STARTUP_NODES, + ['db.redis.is_cluster']: true, + }; + + let cluster: ioredisTypes.Cluster; + + const defaultConfig: IORedisInstrumentationConfig = { + instrumentCluster: true, + }; + + before(done => { + cluster = new ioredisTypes.Cluster( + REDIS_CLUSTER_PORTS.map(port => ({ + host: REDIS_CLUSTER_HOST, + port, + })) + ); + + instrumentation.disable(); + instrumentation = new IORedisInstrumentation(defaultConfig); + instrumentation.setTracerProvider(provider); + + cluster.on('ready', () => { + done(); + }); + + require('ioredis'); + }); + + beforeEach(() => { + memoryExporter.reset(); + }); + + after(done => { + cluster.quit(done); + }); + + describe('Instrumenting query operations - active parent span', () => { + it('should create at least 2 child spans for hset promise', async () => { + const hashKeyName = `hash-{test321}`; + + const attributes = { + ...CLUSTER_DEFAULT_ATTRIBUTES, + [SEMATTRS_DB_STATEMENT]: `hset ${hashKeyName} random [1 other arguments]`, + }; + + const span = provider + .getTracer('ioredis-cluster-test') + .startSpan('test span'); + + await context.with(trace.setSpan(context.active(), span), async () => { + try { + await cluster.hset(hashKeyName, 'random', 'random'); + + // Make sure there are at least 2 spans: test span -> cluster.hset + assert.ok(memoryExporter.getFinishedSpans().length >= 2); + span.end(); + const endedSpans = memoryExporter.getFinishedSpans(); + // Make sure there are at least 3 spans: test span -> cluster.hset -> hset + assert.ok(endedSpans.length >= 3); + + const clientSpan = endedSpans.find(s => s.name === 'hset'); + const clusterSpan = endedSpans.find(s => s.name === 'cluster.hset'); + + assert.ok(clientSpan); + assert.ok(clusterSpan); + + testUtils.assertSpan( + clusterSpan, + SpanKind.CLIENT, + attributes, + [], + unsetStatus + ); + testUtils.assertPropagation(clusterSpan, span); + } catch (error) { + assert.ifError(error); + } + }); + }); + + it('should set span with error when redis return reject', async () => { + const span = provider + .getTracer('ioredis-cluster-test') + .startSpan('test span'); + + await context.with(trace.setSpan(context.active(), span), async () => { + await cluster.set('non-int-key', 'non-int-value'); + try { + // should throw 'ReplyError: ERR value is not an integer or out of range' + // because the value im the key is not numeric and we try to increment it + await cluster.incr('non-int-key'); + } catch (ex: any) { + const endedSpans = memoryExporter.getFinishedSpans(); + + // Make sure there are at least 3 spans: test span -> cluster.incr -> incr + assert.ok(memoryExporter.getFinishedSpans().length >= 3); + const clusterSpan = endedSpans.find(s => s.name === 'cluster.incr'); + assert.ok(clusterSpan); + + // redis 'incr' operation failed with exception, so span should indicate it + assert.strictEqual(clusterSpan.status.code, SpanStatusCode.ERROR); + const exceptionEvent = clusterSpan.events[0]; + assert.strictEqual(exceptionEvent.name, 'exception'); + assert.strictEqual( + exceptionEvent.attributes?.[SEMATTRS_EXCEPTION_MESSAGE], + ex.message + ); + assert.strictEqual( + exceptionEvent.attributes?.[SEMATTRS_EXCEPTION_STACKTRACE], + ex.stack + ); + assert.strictEqual( + exceptionEvent.attributes?.[SEMATTRS_EXCEPTION_TYPE], + ex.name + ); + } + }); + }); + }); + + describe('Instrumenting connect operation - active parent span', () => { + it('should create at least 2 child spans for connect promise', async () => { + const attributes = { + ...CLUSTER_DEFAULT_ATTRIBUTES, + // There are no nodes yet, so we don't have their addresses + ['db.redis.cluster.nodes']: [], + [SEMATTRS_DB_STATEMENT]: 'connect', + }; + + const span = provider + .getTracer('ioredis-cluster-test') + .startSpan('test span'); + + const lazyCluster = new ioredis.Cluster( + REDIS_CLUSTER_PORTS.map(port => ({ + host: REDIS_CLUSTER_HOST, + port, + })), + { + lazyConnect: true, + } + ); + + await context.with(trace.setSpan(context.active(), span), async () => { + try { + await lazyCluster.connect(); + await lazyCluster.quit(); + + // Make sure there are at least 2 spans: test span -> cluster.hset + assert.ok(memoryExporter.getFinishedSpans().length >= 2); + span.end(); + const endedSpans = memoryExporter.getFinishedSpans(); + // Make sure there are at least 3 spans: test span -> cluster.hset -> hset + assert.ok(endedSpans.length >= 3); + + const clientSpan = endedSpans.find(s => s.name === 'connect'); + const clusterSpan = endedSpans.find( + s => s.name === 'cluster.connect' + ); + + assert.ok(clientSpan); + assert.ok(clusterSpan); + + testUtils.assertSpan( + clusterSpan, + SpanKind.CLIENT, + attributes, + [], + unsetStatus + ); + testUtils.assertPropagation(clusterSpan, span); + } catch (error) { + assert.ifError(error); + } + }); + }); + }); + + describe('Instrumenting without parent span', () => { + const testKeyName = 'test-123'; + + before(() => { + instrumentation.disable(); + const config: IORedisInstrumentationConfig = { + requireParentSpan: true, + instrumentCluster: true, + }; + instrumentation.setConfig(config); + instrumentation.enable(); + }); + + it('should not create child span for query', async () => { + await cluster.set(testKeyName, 'data'); + const result = await cluster.del(testKeyName); + assert.strictEqual(result, 1); + assert.strictEqual(memoryExporter.getFinishedSpans().length, 0); + }); + + it('should not create child span for connect', async () => { + const lazyCluster = new ioredis.Cluster( + REDIS_CLUSTER_PORTS.map(port => ({ + host: REDIS_CLUSTER_HOST, + port, + })), + { + lazyConnect: true, + } + ); + await lazyCluster.connect(); + const spans = memoryExporter.getFinishedSpans(); + await lazyCluster.quit(); + assert.strictEqual(spans.length, 0); + }); + }); + + describe('Instrumenting without instrumentCluster: true', () => { + before(() => { + instrumentation.disable(); + instrumentation.setConfig({ + instrumentCluster: false, + requireParentSpan: false, + }); + instrumentation.enable(); + }); + + it('should not create cluster span for query', async () => { + await cluster.set('test-key-123', 'test-value-123'); + + const spans = memoryExporter.getFinishedSpans(); + + const clusterSpan = spans.find(s => s.name === 'cluster.set'); + const setSpan = spans.find(s => s.name === 'set'); + + // Make sure there is at least one span for stand alone client + assert.ok(setSpan); + assert.strictEqual(spans.length > 0, true); + // There should be no cluster span + assert.strictEqual(clusterSpan, undefined); + }); + + it('should not create cluster span for connect', async () => { + const lazyCluster = new ioredis.Cluster( + REDIS_CLUSTER_PORTS.map(port => ({ + host: REDIS_CLUSTER_HOST, + port, + })), + { + lazyConnect: true, + } + ); + + await lazyCluster.connect(); + + const spans = memoryExporter.getFinishedSpans(); + + const clusterSpan = spans.find(s => s.name === 'cluster.connect'); + const setSpan = spans.find(s => s.name === 'connect'); + + await lazyCluster.quit(); + + // Make sure there is at least one span for stand alone client + assert.ok(setSpan); + assert.strictEqual(spans.length > 0, true); + // There should be no cluster span + assert.strictEqual(clusterSpan, undefined); + }); + }); + + describe('Instrumenting with a custom hooks', () => { + before(() => { + instrumentation.disable(); + instrumentation.setConfig(defaultConfig); + instrumentation.enable(); + }); + + it('should call requestHook when set in config', async () => { + const requestHook = sinon.spy( + (span: Span, requestInfo: IORedisRequestHookInformation) => { + span.setAttribute( + 'attribute key from request hook', + 'custom value from request hook' + ); + } + ); + instrumentation.setConfig({ + requestHook, + ...defaultConfig, + }); + + const span = provider.getTracer('ioredis-test').startSpan('test span'); + await context.with(trace.setSpan(context.active(), span), async () => { + await cluster.incr('request-hook-test'); + const endedSpans = memoryExporter.getFinishedSpans(); + + const clusterSpan = endedSpans.find(s => s.name === 'cluster.incr'); + + assert.ok(clusterSpan); + assert.strictEqual( + clusterSpan.attributes['attribute key from request hook'], + 'custom value from request hook' + ); + }); + + // Each child span calls requestHook, hence cluster.incr -> incr >= 2 + assert.ok(requestHook.callCount >= 2); + const [, requestInfo] = requestHook.firstCall.args; + 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']); + }); + + it('should ignore requestHook which throws exception', async () => { + const requestHook = sinon.spy( + (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.setConfig({ + requestHook, + ...defaultConfig, + }); + + const span = provider.getTracer('ioredis-test').startSpan('test span'); + await context.with(trace.setSpan(context.active(), span), async () => { + await cluster.incr('request-hook-throw-test'); + const endedSpans = memoryExporter.getFinishedSpans(); + + const clusterSpan = endedSpans.find(s => s.name === 'cluster.incr'); + const incrSpan = endedSpans.find(s => s.name === 'incr'); + + assert.ok(incrSpan); + + // Make sure there are at least 2 spans: cluster.incr -> incr + assert.strictEqual(endedSpans.length, 2); + assert.ok(clusterSpan); + assert.strictEqual( + clusterSpan.attributes['attribute key BEFORE exception'], + 'this attribute is added to span BEFORE exception is thrown thus we can expect it' + ); + }); + + sinon.assert.threw(requestHook); + }); + + it('should call responseHook when set in config', async () => { + const responseHook = sinon.spy( + ( + span: Span, + cmdName: string, + _cmdArgs: Array, + response: unknown + ) => { + span.setAttribute( + 'attribute key from hook', + 'custom value from hook' + ); + } + ); + instrumentation.setConfig({ + responseHook, + ...defaultConfig, + }); + + const span = provider.getTracer('ioredis-test').startSpan('test span'); + await context.with(trace.setSpan(context.active(), span), async () => { + await cluster.set('response-hook-test', 'test-value'); + const endedSpans = memoryExporter.getFinishedSpans(); + + const clusterSpan = endedSpans.find(s => s.name === 'cluster.set'); + + // Make sure there are at least 2 spans: cluster.set -> set + assert.ok(endedSpans.length >= 2); + assert.ok(clusterSpan); + assert.strictEqual( + clusterSpan.attributes['attribute key from hook'], + 'custom value from hook' + ); + }); + + assert.ok(responseHook.callCount >= 2); + const [, cmdName, , response] = responseHook.firstCall.args as [ + Span, + string, + unknown, + Buffer + ]; + assert.strictEqual(cmdName, 'set'); + assert.strictEqual(response.toString(), 'OK'); + }); + + it('should ignore responseHook which throws exception', async () => { + const responseHook = sinon.spy( + ( + _span: Span, + _cmdName: string, + _cmdArgs: Array, + _response: unknown + ) => { + throw Error('error thrown in responseHook'); + } + ); + instrumentation.setConfig({ + responseHook, + ...defaultConfig, + }); + + const span = provider.getTracer('ioredis-test').startSpan('test span'); + await context.with(trace.setSpan(context.active(), span), async () => { + await cluster.incr('response-hook-throw-test'); + const endedSpans = memoryExporter.getFinishedSpans(); + + const clusterSpan = endedSpans.find(s => s.name === 'cluster.incr'); + assert.ok(clusterSpan); + + // Make sure there are at least 2 spans: cluster.set -> set + assert.ok(endedSpans.length >= 2); + }); + + sinon.assert.threw(responseHook); + }); + }); + + describe('Parse startup nodes', () => { + it('should parse cluster startup nodes', () => { + const combinedStartupNodes = parseStartupNodes([ + '127.0.0.1:3000', + 3001, + { host: 'localhost', port: 3002 }, + ]); + + assert.deepStrictEqual(combinedStartupNodes, [ + '127.0.0.1:3000', + 'localhost:3001', + 'localhost:3002', + ]); + + const invalidStartupNodes = parseStartupNodes({} as any); + + assert.deepStrictEqual(invalidStartupNodes, []); + + const combinedStartupNodes2 = parseStartupNodes([ + 'test.domain.com:123', + { host: 'other.test.domain.com', port: 345 }, + { host: 'test.domain.com', port: 678 }, + ]); + + assert.deepStrictEqual(combinedStartupNodes2, [ + 'test.domain.com:123', + 'other.test.domain.com:345', + 'test.domain.com:678', + ]); + }); + }); + }); }); diff --git a/test/docker-compose.yaml b/test/docker-compose.yaml index 00d2f1ee26..d3f86db319 100644 --- a/test/docker-compose.yaml +++ b/test/docker-compose.yaml @@ -119,3 +119,16 @@ services: interval: 1s timeout: 10s retries: 30 + + redis-cluster: + image: redislabs/client-libs-test:8.0.2 + ports: + - "3000-3005:3000-3005" + environment: + - REDIS_CLUSTER=yes + - NODES=6 + healthcheck: + test: ["CMD-SHELL", "redis-cli -p 3000 cluster info | grep -q 'cluster_state:ok'"] + interval: 1s + timeout: 10s + retries: 30 From 91100a4c82095b2beaa3e646e2c1654b6da502b1 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Fri, 5 Sep 2025 11:16:46 +0300 Subject: [PATCH 3/5] fix(instrumentation-ioredis): add instrumentCluster config option to default settings --- packages/instrumentation-ioredis/src/instrumentation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/instrumentation-ioredis/src/instrumentation.ts b/packages/instrumentation-ioredis/src/instrumentation.ts index e3bd6d1c50..facabefd05 100644 --- a/packages/instrumentation-ioredis/src/instrumentation.ts +++ b/packages/instrumentation-ioredis/src/instrumentation.ts @@ -38,6 +38,7 @@ import { PACKAGE_NAME, PACKAGE_VERSION } from './version'; const DEFAULT_CONFIG: IORedisInstrumentationConfig = { requireParentSpan: true, + instrumentCluster: false, }; export class IORedisInstrumentation extends InstrumentationBase { From cbbffff63b9e8c63774ed48abb7c0948c6a5a3ca Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Fri, 5 Sep 2025 11:41:26 +0300 Subject: [PATCH 4/5] docs(instrumentation-ioredis): document Redis Cluster support and attributes --- packages/instrumentation-ioredis/README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/instrumentation-ioredis/README.md b/packages/instrumentation-ioredis/README.md index d8063be0b8..bdfff1fecb 100644 --- a/packages/instrumentation-ioredis/README.md +++ b/packages/instrumentation-ioredis/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 [`ioredis`](https://github.com/luin/ioredis) 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. +This module provides automatic instrumentation for the [`ioredis`](https://github.com/luin/ioredis) module, supporting both single Redis instances and Redis Clusters (when enabled via configuration). It 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. @@ -52,6 +52,7 @@ IORedis instrumentation has few options available to choose from. You can set th | `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 | +| `instrumentCluster` | `boolean` | Instrument `ioredis` `Cluster` class, default when unset is false | #### Custom db.statement Serializer @@ -112,6 +113,18 @@ Attributes collected: | `net.peer.name` | Remote hostname or similar. | | `net.peer.port` | Remote port number. | +### Cluster Attributes + +When `instrumentCluster: true` is enabled, different attributes are collected for Redis Cluster operations: + +| Attribute | Short Description | +|------------------------|-----------------------------------------------------------------------------| +| `db.redis.cluster.nodes` | Array of cluster node addresses in `host:port` format. | +| `db.redis.cluster.startup_nodes` | Array of startup node addresses used to connect to cluster. | +| `db.redis.is_cluster` | Boolean indicating if this is a cluster operation (always `true`). | + +**Note:** These attributes are only present on spans created for cluster operations (e.g., `cluster.hset`, `cluster.connect`) when cluster instrumentation is enabled. + ## Useful links - For more information on OpenTelemetry, visit: From c874b89b3e1e006b1aef54e076d91afc8e0a67af Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Fri, 19 Sep 2025 14:11:09 +0300 Subject: [PATCH 5/5] chore: fix linting --- .../src/instrumentation.ts | 32 ++++++++++++++----- packages/instrumentation-ioredis/src/utils.ts | 6 ++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/instrumentation-ioredis/src/instrumentation.ts b/packages/instrumentation-ioredis/src/instrumentation.ts index facabefd05..fdee152f59 100644 --- a/packages/instrumentation-ioredis/src/instrumentation.ts +++ b/packages/instrumentation-ioredis/src/instrumentation.ts @@ -21,7 +21,11 @@ import { isWrapped, } from '@opentelemetry/instrumentation'; import { IORedisInstrumentationConfig } from './types'; -import { ClusterInterface, IORedisCommand, RedisInterface } from './internal-types'; +import { + ClusterInterface, + IORedisCommand, + RedisInterface, +} from './internal-types'; import { DBSYSTEMVALUES_REDIS, SEMATTRS_DB_CONNECTION_STRING, @@ -290,9 +294,12 @@ export class IORedisInstrumentation extends InstrumentationBase `${node.options.host}:${node.options.port}`); + + const startupNodes = + 'startupNodes' in this ? parseStartupNodes(this['startupNodes']) : []; + const nodes = this.nodes().map( + node => `${node.options.host}:${node.options.port}` + ); // Create cluster-level parent span const span = instrumentation.tracer.startSpan(`cluster.${cmd.name}`, { @@ -318,7 +325,10 @@ export class IORedisInstrumentation extends InstrumentationBase { if (e) { - diag.error('ioredis cluster instrumentation: request hook failed', e); + diag.error( + 'ioredis cluster instrumentation: request hook failed', + e + ); } }, true @@ -339,7 +349,10 @@ export class IORedisInstrumentation extends InstrumentationBase config.responseHook?.(span, cmd.name, cmd.args, result), e => { if (e) { - diag.error('ioredis cluster instrumentation: response hook failed', e); + diag.error( + 'ioredis cluster instrumentation: response hook failed', + e + ); } }, true @@ -372,8 +385,11 @@ export class IORedisInstrumentation extends InstrumentationBase `${node.options.host}:${node.options.port}`); + const startupNodes = + 'startupNodes' in this ? parseStartupNodes(this['startupNodes']) : []; + const nodes = this.nodes().map( + node => `${node.options.host}:${node.options.port}` + ); const span = instrumentation.tracer.startSpan('cluster.connect', { kind: SpanKind.CLIENT, diff --git a/packages/instrumentation-ioredis/src/utils.ts b/packages/instrumentation-ioredis/src/utils.ts index 232cc2a65a..eb7e44f425 100644 --- a/packages/instrumentation-ioredis/src/utils.ts +++ b/packages/instrumentation-ioredis/src/utils.ts @@ -37,10 +37,10 @@ export const parseStartupNodes = ( return []; } - return startupNodes.map((node) => { - if (typeof node === "string") { + return startupNodes.map(node => { + if (typeof node === 'string') { return node; - } else if (typeof node === "number") { + } else if (typeof node === 'number') { return `localhost:${node}`; } else { return `${node.host}:${node.port}`;