ioredis 5.10.1 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -729,13 +729,16 @@ const stream = redis.zscanStream("myhash", {
729
729
  match: "age:??",
730
730
  });
731
731
  ```
732
+
732
733
  The `hscanStream` also accepts the `noValues` option to specify whether Redis should return only the keys in the hash table without their corresponding values.
734
+
733
735
  ```javascript
734
736
  const stream = redis.hscanStream("myhash", {
735
737
  match: "age:??",
736
738
  noValues: true,
737
739
  });
738
740
  ```
741
+
739
742
  You can learn more from the [Redis documentation](http://redis.io/commands/scan).
740
743
 
741
744
  **Useful Tips**
@@ -815,6 +818,7 @@ const redis = new Redis({
815
818
  ```
816
819
 
817
820
  When enabled:
821
+
818
822
  - For commands with a finite timeout (e.g., `blpop("key", 5)`), ioredis sets a client-side deadline based on the command's timeout plus a small grace period (`blockingTimeoutGrace`, default 100ms). If no reply arrives before the deadline, the command resolves with `null`—the same value Redis returns when a blocking command times out normally.
819
823
  - For commands that block forever (e.g., `timeout = 0` or `BLOCK 0`), the `blockingTimeout` value is used as a safety net.
820
824
 
@@ -842,15 +846,15 @@ On ElastiCache instances with Auto-failover enabled, `reconnectOnError` does not
842
846
 
843
847
  The Redis instance will emit some events about the state of the connection to the Redis server.
844
848
 
845
- | Event | Description |
846
- | :----------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
847
- | connect | emits when a connection is established to the Redis server. |
848
- | ready | If `enableReadyCheck` is `true`, client will emit `ready` when the server reports that it is ready to receive commands (e.g. finish loading data from disk).<br>Otherwise, `ready` will be emitted immediately right after the `connect` event. |
849
- | error | emits when an error occurs while connecting.<br>However, ioredis emits all `error` events silently (only emits when there's at least one listener) so that your application won't crash if you're not listening to the `error` event.<br>When `redis.connect()` is explicitly called the error will also be rejected from the returned promise, in addition to emitting it. If `redis.connect()` is not called explicitly and `lazyConnect` is true, ioredis will try to connect automatically on the first command and emit the `error` event silently. |
850
- | close | emits when an established Redis server connection has closed. |
851
- | reconnecting | emits after `close` when a reconnection will be made. The argument of the event is the time (in ms) before reconnecting. |
852
- | end | emits after `close` when no more reconnections will be made, or the connection is failed to establish. |
853
- | wait | emits when `lazyConnect` is set and will wait for the first command to be called before connecting. |
849
+ | Event | Description |
850
+ | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
851
+ | connect | emits when a connection is established to the Redis server. |
852
+ | ready | If `enableReadyCheck` is `true`, client will emit `ready` when the server reports that it is ready to receive commands (e.g. finish loading data from disk).<br>Otherwise, `ready` will be emitted immediately right after the `connect` event. |
853
+ | error | emits when an error occurs while connecting.<br>However, ioredis emits all `error` events silently (only emits when there's at least one listener) so that your application won't crash if you're not listening to the `error` event.<br>When `redis.connect()` is explicitly called the error will also be rejected from the returned promise, in addition to emitting it. If `redis.connect()` is not called explicitly and `lazyConnect` is true, ioredis will try to connect automatically on the first command and emit the `error` event silently. |
854
+ | close | emits when an established Redis server connection has closed. |
855
+ | reconnecting | emits after `close` when a reconnection will be made. The argument of the event is the time (in ms) before reconnecting. |
856
+ | end | emits after `close` when no more reconnections will be made, or the connection is failed to establish. |
857
+ | wait | emits when `lazyConnect` is set and will wait for the first command to be called before connecting. |
854
858
 
855
859
  You can also check out the `Redis#status` property to get the current connection status.
856
860
 
@@ -860,6 +864,38 @@ Besides the above connection events, there are several other custom events:
860
864
  | :----- | :------------------------------------------------------------------ |
861
865
  | select | emits when the database changed. The argument is the new db number. |
862
866
 
867
+ ## Diagnostics Channel
868
+
869
+ ioredis publishes telemetry through Node.js [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html), allowing APM tools and custom instrumentation to observe commands, connections, and batch operations without modifying application code.
870
+
871
+ These channels use `TracingChannel#tracePromise()` and emit `start`, `end`, `asyncStart`, `asyncEnd`, and `error` sub-events. Requires Node.js >= 18.19.0; on older versions the channels are silently unavailable (zero overhead). Subscribe via `tracing:<name>:<event>`:
872
+
873
+ ```typescript
874
+ import dc from "node:diagnostics_channel";
875
+
876
+ dc.subscribe("tracing:ioredis:command:start", ({ command, args }) => {
877
+ console.log(`> ${command}`, args);
878
+ });
879
+
880
+ dc.subscribe("tracing:ioredis:command:asyncEnd", ({ command }) => {
881
+ console.log(`${command} settled`);
882
+ });
883
+
884
+ dc.subscribe("tracing:ioredis:command:error", ({ command, error }) => {
885
+ console.error(`${command} failed:`, error);
886
+ });
887
+ ```
888
+
889
+ | Channel name | Payload | Description |
890
+ | :---------------- | :---------------------- | :------------------------------------------------------- |
891
+ | `ioredis:command` | `CommandTraceContext` | Individual command (standalone, pipeline, or within MULTI) |
892
+ | `ioredis:batch` | `BatchOperationContext` | MULTI transaction as a whole |
893
+ | `ioredis:connect` | `ConnectTraceContext` | Socket connection attempt |
894
+
895
+ Command arguments are sanitized before emission using rules adapted from `@opentelemetry/redis-common`. Sensitive values (e.g. values in `SET`, `AUTH` passwords) are replaced with `?`, while read-only commands like `GET` and `DEL` retain all arguments. This is safe by default: unlisted or custom commands have all arguments redacted.
896
+
897
+ Context types (`CommandTraceContext`, `BatchOperationContext`, `ConnectTraceContext`) are exported from `ioredis`.
898
+
863
899
  ## Offline Queue
864
900
 
865
901
  When a command can't be processed by Redis (being sent before the `ready` event), by default, it's added to the offline queue and will be
@@ -1159,6 +1195,7 @@ const cluster = new Redis.Cluster(
1159
1195
  ```
1160
1196
 
1161
1197
  Or you can specify this parameter through function:
1198
+
1162
1199
  ```javascript
1163
1200
  const cluster = new Redis.Cluster(
1164
1201
  [
@@ -1169,7 +1206,7 @@ const cluster = new Redis.Cluster(
1169
1206
  ],
1170
1207
  {
1171
1208
  natMap: (key) => {
1172
- if(key.includes('30001')) {
1209
+ if (key.includes("30001")) {
1173
1210
  return { host: "203.0.113.73", port: 30001 };
1174
1211
  }
1175
1212
 
@@ -1226,28 +1263,33 @@ For sharded Pub/Sub, use the `spublish` and `ssubscribe` commands instead of the
1226
1263
  The following basic example shows you how to use sharded Pub/Sub:
1227
1264
 
1228
1265
  ```javascript
1229
- const cluster: Cluster = new Cluster([{host: host, port: port}], {shardedSubscribers: true});
1266
+ const cluster: Cluster = new Cluster([{ host: host, port: port }], {
1267
+ shardedSubscribers: true,
1268
+ });
1230
1269
 
1231
1270
  //Register the callback
1232
1271
  cluster.on("smessage", (channel, message) => {
1233
- console.log(message);
1272
+ console.log(message);
1234
1273
  });
1235
1274
 
1236
-
1237
1275
  //Subscribe to the channels on the same slot
1238
- cluster.ssubscribe("channel{my}:1", "channel{my}:2").then( ( count: number ) => {
1276
+ cluster
1277
+ .ssubscribe("channel{my}:1", "channel{my}:2")
1278
+ .then((count: number) => {
1239
1279
  console.log(count);
1240
- }).catch( (err) => {
1280
+ })
1281
+ .catch((err) => {
1241
1282
  console.log(err);
1242
- });
1283
+ });
1243
1284
 
1244
1285
  //Publish a message
1245
- cluster.spublish("channel{my}:1", "This is a test message to my first channel.").then((value: number) => {
1286
+ cluster
1287
+ .spublish("channel{my}:1", "This is a test message to my first channel.")
1288
+ .then((value: number) => {
1246
1289
  console.log("Published a message to channel{my}:1");
1247
- });
1290
+ });
1248
1291
  ```
1249
1292
 
1250
-
1251
1293
  ### Events
1252
1294
 
1253
1295
  | Event | Description |
@@ -94,6 +94,7 @@ export default class Command implements Respondable {
94
94
  args: CommandParameter[];
95
95
  inTransaction: boolean;
96
96
  pipelineIndex?: number;
97
+ isTraced: boolean;
97
98
  isResolved: boolean;
98
99
  reject: (err: Error) => void;
99
100
  resolve: (result: any) => void;
package/built/Command.js CHANGED
@@ -37,6 +37,7 @@ class Command {
37
37
  constructor(name, args = [], options = {}, callback) {
38
38
  this.name = name;
39
39
  this.inTransaction = false;
40
+ this.isTraced = false;
40
41
  this.isResolved = false;
41
42
  this.transformed = false;
42
43
  this.replyEncoding = options.replyEncoding;
package/built/Pipeline.js CHANGED
@@ -5,6 +5,7 @@ const commands_1 = require("@ioredis/commands");
5
5
  const standard_as_callback_1 = require("standard-as-callback");
6
6
  const util_1 = require("util");
7
7
  const Command_1 = require("./Command");
8
+ const buffer_1 = require("buffer");
8
9
  const utils_1 = require("./utils");
9
10
  const Commander_1 = require("./utils/Commander");
10
11
  /*
@@ -315,6 +316,15 @@ Pipeline.prototype.exec = function (callback) {
315
316
  buffers.push(writable);
316
317
  }
317
318
  else {
319
+ if (data.length + writable.length >= buffer_1.constants.MAX_STRING_LENGTH) {
320
+ if (!buffers) {
321
+ buffers = [];
322
+ }
323
+ if (data) {
324
+ buffers.push(Buffer.from(data, "utf8"));
325
+ data = "";
326
+ }
327
+ }
318
328
  data += writable;
319
329
  }
320
330
  if (!--writePending) {
package/built/Redis.d.ts CHANGED
@@ -7,6 +7,7 @@ import { RedisOptions } from "./redis/RedisOptions";
7
7
  import ScanStream from "./ScanStream";
8
8
  import { Transaction } from "./transaction";
9
9
  import { Callback, CommandItem, NetStream, ScanStreamOptions, WriteableStream } from "./types";
10
+ import { type BatchOperationContext } from "./tracing";
10
11
  import Commander from "./utils/Commander";
11
12
  import Deque = require("denque");
12
13
  declare type RedisStatus = "wait" | "reconnecting" | "connecting" | "connect" | "ready" | "close" | "end";
@@ -85,6 +86,7 @@ declare class Redis extends Commander implements DataHandledable {
85
86
  * if the connection fails, times out, or if Redis is already connecting/connected.
86
87
  */
87
88
  connect(callback?: Callback<void>): Promise<void>;
89
+ private _connect;
88
90
  /**
89
91
  * Disconnect from Redis.
90
92
  *
@@ -186,6 +188,15 @@ declare class Redis extends Commander implements DataHandledable {
186
188
  * @ignore
187
189
  */
188
190
  handleReconnection(err: Error, item: CommandItem): void;
191
+ /**
192
+ * @ignore
193
+ */
194
+ _getServerAddress(): {
195
+ address: string;
196
+ port: number | undefined;
197
+ };
198
+ private _buildCommandContext;
199
+ _buildBatchContext(batchSize: number): BatchOperationContext;
189
200
  /**
190
201
  * Get description of the connection. Used for debugging.
191
202
  */
package/built/Redis.js CHANGED
@@ -12,6 +12,7 @@ const RedisOptions_1 = require("./redis/RedisOptions");
12
12
  const ScanStream_1 = require("./ScanStream");
13
13
  const transaction_1 = require("./transaction");
14
14
  const utils_1 = require("./utils");
15
+ const tracing_1 = require("./tracing");
15
16
  const applyMixin_1 = require("./utils/applyMixin");
16
17
  const Commander_1 = require("./utils/Commander");
17
18
  const lodash_1 = require("./utils/lodash");
@@ -101,7 +102,18 @@ class Redis extends Commander_1.default {
101
102
  * if the connection fails, times out, or if Redis is already connecting/connected.
102
103
  */
103
104
  connect(callback) {
104
- const promise = new Promise((resolve, reject) => {
105
+ const promise = (0, tracing_1.traceConnect)(() => this._connect(), () => {
106
+ const { address, port } = this._getServerAddress();
107
+ return {
108
+ serverAddress: address,
109
+ serverPort: port,
110
+ connectionEpoch: this.connectionEpoch,
111
+ };
112
+ });
113
+ return (0, standard_as_callback_1.default)(promise, callback);
114
+ }
115
+ _connect() {
116
+ return new Promise((resolve, reject) => {
105
117
  if (this.status === "connecting" ||
106
118
  this.status === "connect" ||
107
119
  this.status === "ready") {
@@ -211,7 +223,6 @@ class Redis extends Commander_1.default {
211
223
  _this.once("close", connectionCloseHandler);
212
224
  });
213
225
  });
214
- return (0, standard_as_callback_1.default)(promise, callback);
215
226
  }
216
227
  /**
217
228
  * Disconnect from Redis.
@@ -416,7 +427,8 @@ class Redis extends Commander_1.default {
416
427
  if (Command_1.default.checkFlag("WILL_DISCONNECT", command.name)) {
417
428
  this.manuallyClosing = true;
418
429
  }
419
- if (this.options.socketTimeout !== undefined && this.socketTimeoutTimer === undefined) {
430
+ if (this.options.socketTimeout !== undefined &&
431
+ this.socketTimeoutTimer === undefined) {
420
432
  this.setSocketTimeout();
421
433
  }
422
434
  }
@@ -428,7 +440,15 @@ class Redis extends Commander_1.default {
428
440
  debug("switch to db [%d]", this.condition.select);
429
441
  }
430
442
  }
431
- return command.promise;
443
+ if (!writable || command.isTraced) {
444
+ return command.promise;
445
+ }
446
+ // Trace on the write path only, and only once per command. Commands may
447
+ // pass through sendCommand multiple times (offline queue flush,
448
+ // prevCommandQueue resend after reconnect). The isTraced flag ensures
449
+ // we don't emit duplicate trace events.
450
+ command.isTraced = true;
451
+ return (0, tracing_1.traceCommand)(() => command.promise, () => this._buildCommandContext(command));
432
452
  }
433
453
  getBlockingTimeoutInMs(command) {
434
454
  var _a;
@@ -444,7 +464,8 @@ class Redis extends Commander_1.default {
444
464
  if (typeof timeout === "number") {
445
465
  if (timeout > 0) {
446
466
  // Finite timeout from command args - add grace period
447
- return timeout + ((_a = this.options.blockingTimeoutGrace) !== null && _a !== void 0 ? _a : RedisOptions_1.DEFAULT_REDIS_OPTIONS.blockingTimeoutGrace);
467
+ return (timeout +
468
+ ((_a = this.options.blockingTimeoutGrace) !== null && _a !== void 0 ? _a : RedisOptions_1.DEFAULT_REDIS_OPTIONS.blockingTimeoutGrace));
448
469
  }
449
470
  // Command has timeout=0 (block forever), use blockingTimeout option as safety net
450
471
  return configuredTimeout;
@@ -575,6 +596,40 @@ class Redis extends Commander_1.default {
575
596
  item.command.reject(err);
576
597
  }
577
598
  }
599
+ /**
600
+ * @ignore
601
+ */
602
+ _getServerAddress() {
603
+ if ("path" in this.options && this.options.path) {
604
+ return { address: this.options.path, port: undefined };
605
+ }
606
+ return {
607
+ address: ("host" in this.options && this.options.host) || "localhost",
608
+ port: ("port" in this.options && this.options.port) || 6379,
609
+ };
610
+ }
611
+ _buildCommandContext(command) {
612
+ var _a, _b, _c;
613
+ const { address, port } = this._getServerAddress();
614
+ return {
615
+ command: command.name,
616
+ args: (0, tracing_1.sanitizeArgs)(command.name, command.args),
617
+ database: (_c = (_b = (_a = this.condition) === null || _a === void 0 ? void 0 : _a.select) !== null && _b !== void 0 ? _b : this.options.db) !== null && _c !== void 0 ? _c : 0,
618
+ serverAddress: address,
619
+ serverPort: port,
620
+ };
621
+ }
622
+ _buildBatchContext(batchSize) {
623
+ var _a, _b, _c;
624
+ const { address, port } = this._getServerAddress();
625
+ return {
626
+ batchMode: "MULTI",
627
+ batchSize,
628
+ database: (_c = (_b = (_a = this.condition) === null || _a === void 0 ? void 0 : _a.select) !== null && _b !== void 0 ? _b : this.options.db) !== null && _c !== void 0 ? _c : 0,
629
+ serverAddress: address,
630
+ serverPort: port,
631
+ };
632
+ }
578
633
  /**
579
634
  * Get description of the connection. Used for debugging.
580
635
  */
@@ -100,6 +100,18 @@ function getFirstValueInFlattenedArray(args) {
100
100
  return undefined;
101
101
  }
102
102
  exports.getFirstValueInFlattenedArray = getFirstValueInFlattenedArray;
103
+ function getFirstKeyForCommand(commandName, args) {
104
+ if ((0, commands_1.exists)(commandName, { caseInsensitive: true })) {
105
+ const flattenedArgs = args.flat();
106
+ const keyIndexes = (0, commands_1.getKeyIndexes)(commandName, flattenedArgs, {
107
+ nameCaseInsensitive: true,
108
+ });
109
+ if (keyIndexes.length) {
110
+ return flattenedArgs[keyIndexes[0]];
111
+ }
112
+ }
113
+ return getFirstValueInFlattenedArray(args);
114
+ }
103
115
  function executeWithAutoPipelining(client, functionName, commandName, args, callback) {
104
116
  // On cluster mode let's wait for slots to be available
105
117
  if (client.isCluster && !client.slots.length) {
@@ -116,11 +128,9 @@ function executeWithAutoPipelining(client, functionName, commandName, args, call
116
128
  }), callback);
117
129
  }
118
130
  // If we have slot information, we can improve routing by grouping slots served by the same subset of nodes
119
- // Note that the first value in args may be a (possibly empty) array.
120
- // ioredis will only flatten one level of the array, in the Command constructor.
121
131
  const prefix = client.options.keyPrefix || "";
122
132
  let slotKey = client.isCluster
123
- ? client.slots[calculateSlot(`${prefix}${getFirstValueInFlattenedArray(args)}`)].join(",")
133
+ ? client.slots[calculateSlot(`${prefix}${getFirstKeyForCommand(commandName, args)}`)].join(",")
124
134
  : "main";
125
135
  // When scaleReads is enabled, separate read and write commands into different pipelines
126
136
  // so they can be routed to replicas and masters respectively
package/built/index.d.ts CHANGED
@@ -36,6 +36,7 @@ export { ClusterNode } from "./cluster";
36
36
  export { ClusterOptions, DNSLookupFunction, DNSResolveSrvFunction, NatMap, } from "./cluster/ClusterOptions";
37
37
  export { NodeRole } from "./cluster/util";
38
38
  export type { RedisKey, RedisValue, ChainableCommander, } from "./utils/RedisCommander";
39
+ export type { CommandTraceContext, BatchOperationContext, ConnectTraceContext, } from "./tracing";
39
40
  export declare const ReplyError: any;
40
41
  /**
41
42
  * @ignore
@@ -0,0 +1,26 @@
1
+ import type { CommandParameter } from "./types";
2
+ export declare function sanitizeArgs(commandName: string, args: CommandParameter[]): string[];
3
+ export interface CommandTraceContext {
4
+ command: string;
5
+ args: string[];
6
+ database: number;
7
+ serverAddress: string;
8
+ serverPort: number | undefined;
9
+ }
10
+ export interface BatchOperationContext {
11
+ batchMode: "MULTI";
12
+ batchSize: number;
13
+ database: number;
14
+ serverAddress: string;
15
+ serverPort: number | undefined;
16
+ }
17
+ export interface ConnectTraceContext {
18
+ serverAddress: string;
19
+ serverPort: number | undefined;
20
+ connectionEpoch: number;
21
+ }
22
+ declare type CommandContext = CommandTraceContext;
23
+ export declare function traceCommand<T>(fn: () => Promise<T>, contextFactory: () => CommandContext): Promise<T>;
24
+ export declare function traceBatch<T>(fn: () => Promise<T>, contextFactory: () => BatchOperationContext): Promise<T>;
25
+ export declare function traceConnect<T>(fn: () => Promise<T>, contextFactory: () => ConnectTraceContext): Promise<T>;
26
+ export {};
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.traceConnect = exports.traceBatch = exports.traceCommand = exports.sanitizeArgs = void 0;
4
+ // Argument sanitization rules adapted from @opentelemetry/redis-common (Apache 2.0).
5
+ // https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/redis-common/src/index.ts
6
+ //
7
+ // Each entry specifies how many positional args (after the command name) are safe
8
+ // to emit. -1 means all args are safe (read-only/structural commands).
9
+ // Unlisted commands default to 0 (all args redacted) for safe-by-default behavior,
10
+ // which covers AUTH, HELLO, and unknown/custom commands.
11
+ const SERIALIZATION_SUBSETS = [
12
+ { regex: /^ECHO/i, args: 0 },
13
+ {
14
+ regex: /^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i,
15
+ args: 1,
16
+ },
17
+ { regex: /^(HSET|HMSET|LSET|LINSERT)/i, args: 2 },
18
+ {
19
+ regex: /^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i,
20
+ args: -1,
21
+ },
22
+ ];
23
+ function sanitizeArgs(commandName, args) {
24
+ let allowedArgCount = 0;
25
+ for (const subset of SERIALIZATION_SUBSETS) {
26
+ if (subset.regex.test(commandName)) {
27
+ allowedArgCount = subset.args;
28
+ break;
29
+ }
30
+ }
31
+ if (allowedArgCount === -1) {
32
+ return args.map((a) => String(a));
33
+ }
34
+ const result = [];
35
+ for (let i = 0; i < args.length; i++) {
36
+ if (i < allowedArgCount) {
37
+ result.push(String(args[i]));
38
+ }
39
+ else {
40
+ result.push("?");
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+ exports.sanitizeArgs = sanitizeArgs;
46
+ // Load diagnostics_channel with Node 18 compatibility
47
+ const dc = (() => {
48
+ try {
49
+ return "getBuiltinModule" in process
50
+ ? process.getBuiltinModule("node:diagnostics_channel")
51
+ : require("node:diagnostics_channel");
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ })();
57
+ const hasTracingChannel = dc && typeof dc.tracingChannel === "function";
58
+ const commandChannel = hasTracingChannel
59
+ ? dc.tracingChannel("ioredis:command")
60
+ : undefined;
61
+ const batchChannel = hasTracingChannel
62
+ ? dc.tracingChannel("ioredis:batch")
63
+ : undefined;
64
+ const connectChannel = hasTracingChannel
65
+ ? dc.tracingChannel("ioredis:connect")
66
+ : undefined;
67
+ function shouldTrace(channel) {
68
+ return !!channel && channel.hasSubscribers !== false;
69
+ }
70
+ const noop = () => { };
71
+ function traceCommand(fn, contextFactory) {
72
+ if (!shouldTrace(commandChannel))
73
+ return fn();
74
+ // tracePromise returns a wrapper promise that re-rejects on error.
75
+ // Silence the wrapper to prevent unhandled rejections when callers
76
+ // (e.g. Pipeline) discard the return value. Callers that await this
77
+ // promise still see the rejection through their own .then() chain.
78
+ const traced = commandChannel.tracePromise(fn, contextFactory());
79
+ traced.catch(noop);
80
+ return traced;
81
+ }
82
+ exports.traceCommand = traceCommand;
83
+ function traceBatch(fn, contextFactory) {
84
+ if (!shouldTrace(batchChannel))
85
+ return fn();
86
+ const traced = batchChannel.tracePromise(fn, contextFactory());
87
+ traced.catch(noop);
88
+ return traced;
89
+ }
90
+ exports.traceBatch = traceBatch;
91
+ function traceConnect(fn, contextFactory) {
92
+ if (!shouldTrace(connectChannel))
93
+ return fn();
94
+ return connectChannel.tracePromise(fn, contextFactory());
95
+ }
96
+ exports.traceConnect = traceConnect;
@@ -4,6 +4,7 @@ exports.addTransactionSupport = void 0;
4
4
  const utils_1 = require("./utils");
5
5
  const standard_as_callback_1 = require("standard-as-callback");
6
6
  const Pipeline_1 = require("./Pipeline");
7
+ const tracing_1 = require("./tracing");
7
8
  function addTransactionSupport(redis) {
8
9
  redis.pipeline = function (commands) {
9
10
  const pipeline = new Pipeline_1.default(this);
@@ -51,8 +52,9 @@ function addTransactionSupport(redis) {
51
52
  if (this.nodeifiedPromise) {
52
53
  return exec.call(pipeline);
53
54
  }
54
- const promise = exec.call(pipeline);
55
- return (0, standard_as_callback_1.default)(promise.then(function (result) {
55
+ // Count only user commands (exclude MULTI/EXEC wrappers).
56
+ const batchSize = Math.max(pipeline.length - 2, 0);
57
+ const execAndUnwrap = () => exec.call(pipeline).then(function (result) {
56
58
  const execResult = result[result.length - 1];
57
59
  if (typeof execResult === "undefined") {
58
60
  throw new Error("Pipeline cannot be used to send any commands when the `exec()` has been called on it.");
@@ -67,7 +69,12 @@ function addTransactionSupport(redis) {
67
69
  throw execResult[0];
68
70
  }
69
71
  return (0, utils_1.wrapMultiResult)(execResult[1]);
70
- }), callback);
72
+ });
73
+ // Trace MULTI as a single batch operation on ioredis:batch.
74
+ const promise = "_buildBatchContext" in this.redis
75
+ ? (0, tracing_1.traceBatch)(execAndUnwrap, () => this.redis._buildBatchContext(batchSize))
76
+ : execAndUnwrap();
77
+ return (0, standard_as_callback_1.default)(promise, callback);
71
78
  };
72
79
  // @ts-expect-error
73
80
  const { execBuffer } = pipeline;