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:
diff --git a/packages/instrumentation-ioredis/package.json b/packages/instrumentation-ioredis/package.json
index 7bcb694518..d8d672df66 100644
--- a/packages/instrumentation-ioredis/package.json
+++ b/packages/instrumentation-ioredis/package.json
@@ -23,8 +23,8 @@
"test:debug": "cross-env RUN_REDIS_TESTS=true mocha --inspect-brk --no-timeouts 'test/**/*.test.ts'",
"test:with-services-env": "cross-env NODE_OPTIONS='-r dotenv/config' DOTENV_CONFIG_PATH=../../test/test-services.env npm test",
"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/src/instrumentation.ts b/packages/instrumentation-ioredis/src/instrumentation.ts
index 07e14fe02c..3e757539b6 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 { IORedisCommand, RedisInterface } from './internal-types';
+import {
+ ClusterInterface,
+ IORedisCommand,
+ RedisInterface,
+} from './internal-types';
import {
DB_SYSTEM_VALUE_REDIS,
ATTR_DB_CONNECTION_STRING,
@@ -31,13 +35,14 @@ import {
ATTR_NET_PEER_PORT,
} from './semconv';
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';
const DEFAULT_CONFIG: IORedisInstrumentationConfig = {
requireParentSpan: true,
+ instrumentCluster: false,
};
export class IORedisInstrumentation extends InstrumentationBase {
@@ -59,6 +64,7 @@ export class IORedisInstrumentation extends InstrumentationBase {
@@ -83,8 +114,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 +276,155 @@ 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..eb7e44f425 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}`;
+ }
+ });
+};
diff --git a/packages/instrumentation-ioredis/test/ioredis.test.ts b/packages/instrumentation-ioredis/test/ioredis.test.ts
index 91297c3606..7f8182dbf8 100644
--- a/packages/instrumentation-ioredis/test/ioredis.test.ts
+++ b/packages/instrumentation-ioredis/test/ioredis.test.ts
@@ -44,6 +44,7 @@ import {
ATTR_EXCEPTION_STACKTRACE,
ATTR_EXCEPTION_TYPE,
} from '@opentelemetry/semantic-conventions';
+import { parseStartupNodes } from '../src/utils';
import {
DB_SYSTEM_VALUE_REDIS,
ATTR_DB_CONNECTION_STRING,
@@ -1018,4 +1019,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