Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/instrumentation-ioredis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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: <https://opentelemetry.io/>
Expand Down
4 changes: 2 additions & 2 deletions packages/instrumentation-ioredis/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
208 changes: 206 additions & 2 deletions packages/instrumentation-ioredis/src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<IORedisInstrumentationConfig> {
Expand All @@ -59,6 +64,7 @@ export class IORedisInstrumentation extends InstrumentationBase<IORedisInstrumen
module[Symbol.toStringTag] === 'Module'
? module.default // ESM
: module; // CommonJS

if (isWrapped(moduleExports.prototype.sendCommand)) {
this._unwrap(moduleExports.prototype, 'sendCommand');
}
Expand All @@ -75,6 +81,31 @@ export class IORedisInstrumentation extends InstrumentationBase<IORedisInstrumen
'connect',
this._patchConnection()
);

if (this.getConfig().instrumentCluster) {
const ClusterConstructor = moduleExports.Cluster;

if (ClusterConstructor?.prototype) {
if (isWrapped(ClusterConstructor.prototype.sendCommand)) {
this._unwrap(ClusterConstructor.prototype, 'sendCommand');
}
this._wrap(
ClusterConstructor.prototype,
'sendCommand',
this._patchClusterSendCommand(moduleVersion)
);

if (isWrapped(ClusterConstructor.prototype.connect)) {
this._unwrap(ClusterConstructor.prototype, 'connect');
}
this._wrap(
ClusterConstructor.prototype,
'connect',
this._patchClusterConnect()
);
}
}

return module;
},
module => {
Expand All @@ -83,8 +114,18 @@ export class IORedisInstrumentation extends InstrumentationBase<IORedisInstrumen
module[Symbol.toStringTag] === 'Module'
? module.default // ESM
: module; // CommonJS

this._unwrap(moduleExports.prototype, 'sendCommand');
this._unwrap(moduleExports.prototype, 'connect');

if (this.getConfig().instrumentCluster) {
const ClusterConstructor = moduleExports.Cluster;

if (ClusterConstructor?.prototype) {
this._unwrap(ClusterConstructor.prototype, 'sendCommand');
this._unwrap(ClusterConstructor.prototype, 'connect');
}
}
}
),
];
Expand All @@ -105,6 +146,18 @@ export class IORedisInstrumentation extends InstrumentationBase<IORedisInstrumen
};
}

private _patchClusterSendCommand(moduleVersion?: string) {
return (original: Function) => {
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) {
Expand Down Expand Up @@ -223,4 +276,155 @@ export class IORedisInstrumentation extends InstrumentationBase<IORedisInstrumen
}
};
}

private _traceClusterSendCommand(original: Function, moduleVersion?: string) {
const instrumentation = this;
return function (this: ClusterInterface, cmd?: IORedisCommand) {
if (arguments.length < 1 || typeof cmd !== 'object') {
return original.apply(this, arguments);
}

const config = instrumentation.getConfig();
const hasNoParentSpan = trace.getSpan(context.active()) === undefined;

// Skip if requireParentSpan is true and no parent exists
if (config.requireParentSpan === true && hasNoParentSpan) {
return original.apply(this, arguments);
}

const dbStatementSerializer =
config.dbStatementSerializer || defaultDbStatementSerializer;

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}`, {
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;
}
};
}
}
3 changes: 2 additions & 1 deletion packages/instrumentation-ioredis/src/internal-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,3 +27,4 @@ interface LegacyIORedisCommand {

export type IORedisCommand = Command | LegacyIORedisCommand;
export type RedisInterface = Redis | LegacyIORedis.Redis;
export type ClusterInterface = Cluster | LegacyIORedis.Cluster;
3 changes: 3 additions & 0 deletions packages/instrumentation-ioredis/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
18 changes: 18 additions & 0 deletions packages/instrumentation-ioredis/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,21 @@ export const endSpan = (
}
span.end();
};

export const parseStartupNodes = (
startupNodes?: Array<string | number | { host: string; port: number }>
): Array<string> => {
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}`;
}
});
};
Loading
Loading