This is an automated email from the ASF dual-hosted git repository.

RongtongJin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git


The following commit(s) were added to refs/heads/master by this push:
     new 8fd7226c Feat/nodejs metrics export (#1387)
8fd7226c is described below

commit 8fd7226cd3fd64b30c3afa2a7386e8058de7f41b
Author: zhaohai <[email protected]>
AuthorDate: Fri Oct 9 10:45:00 2026 +0800

    Feat/nodejs metrics export (#1387)
    
    * feat(nodejs): support NOTIFY_UNSUBSCRIBE_LITE_COMMAND and message 
interceptor hooks
    
    - BaseClient: add onNotifyUnsubscribeLiteCommand hook (warn by default,
      overridden by lite push consumers)
    - TelemetrySession: dispatch NOTIFY_UNSUBSCRIBE_LITE_COMMAND (was falling
      through to the unknown-command branch)
    - LitePushConsumerImpl: override the command handler with the 
endpoints-aware
      signature and forward to the lite subscription manager
    - BaseClient: drop the commented-out recover-orphaned-transaction reply
      (warn only, no reply)
    - New hook package: MessageHookPoints, MessageHookPointsStatus,
      Attribute/AttributeKey, MessageInterceptor(+Context/Impl),
      CompositedMessageInterceptor and InflightRequestCountInterceptor; clients
      expose addMessageInterceptor and doBefore/doAfter, configurable via
      BaseClientOptions#messageInterceptor
    - Wire hook points: SEND (producer), RECEIVE/ACK/CHANGE_INVISIBLE_DURATION
      (consumer), CONSUME (consume task); push consumers register the built-in
      inflight request count interceptor
    - Tests: cover telemetry dispatch and interceptor chain ordering, error
      isolation, attribute propagation and inflight counting
    
    * feat(nodejs): wire COMMIT_TRANSACTION / ROLLBACK_TRANSACTION / 
FORWARD_TO_DLQ hook points
    
    Complete the remaining 3 of the 8 MessageHookPoints:
    
    - K-2 Producer#endTransaction fires COMMIT_TRANSACTION / 
ROLLBACK_TRANSACTION
      (selected by resolution === COMMIT), hook status OK/ERROR taken from the
      response Code or the thrown error.
    - K-3 Consumer#forwardMessageToDeadLetterQueueViaRpc fires FORWARD_TO_DLQ
      around every DLQ RPC attempt. ProcessQueue now forwards the MessageView
      so interceptors observe the real message.
    - Add 4 regression tests (transaction hooks, DLQ hook, error paths);
      offline suite 119/119, integration 16/16.
    
    * feat(nodejs): add metrics export pipeline over OpenTelemetry / OTLP gRPC
    
    Add src/metrics/*:
    - ClientMeterManager rebuilds an OpenTelemetry SdkMeterProvider on every
      settings command, exporting the four RocketMQ histograms (send cost
      time, delivery latency, await time, process time) with explicit bucket
      boundaries, plus consumer cached-message gauges. Every export request is
      signed through the client's MQv2-HMAC-SHA1 metadata provider.
    - MessageMeterInterceptor records the histograms at the SEND / RECEIVE /
      CONSUME hook points via the interceptor chain.
    - PushConsumerGaugeObserver aggregates cached messages/bytes per topic.
    - BaseClient wires the manager (created in the constructor, reset from
      onSettingsCommand, shut down on client shutdown).
---
 nodejs/package-lock.json                           | 211 ++++++++++++
 nodejs/package.json                                |   6 +-
 nodejs/src/client/BaseClient.ts                    |  97 +++++-
 nodejs/src/consumer/ConsumeService.ts              |  11 +-
 nodejs/src/consumer/ConsumeTask.ts                 |  46 ++-
 nodejs/src/consumer/Consumer.ts                    |  82 ++++-
 nodejs/src/consumer/FifoConsumeService.ts          |   6 +-
 nodejs/src/consumer/LiteFifoConsumeService.ts      |   6 +-
 nodejs/src/consumer/LitePushConsumerImpl.ts        |  11 +-
 nodejs/src/consumer/LiteStandardConsumeService.ts  |   6 +-
 nodejs/src/consumer/ProcessQueue.ts                |   9 +
 nodejs/src/consumer/PushConsumer.ts                |  36 ++-
 nodejs/src/consumer/PushConsumerGaugeObserver.ts   |  60 ++++
 nodejs/src/consumer/StandardConsumeService.ts      |   6 +-
 nodejs/src/{index.ts => hook/Attribute.ts}         |  26 +-
 nodejs/src/{index.ts => hook/AttributeKey.ts}      |  32 +-
 nodejs/src/hook/CompositedMessageInterceptor.ts    |  98 ++++++
 nodejs/src/hook/InflightRequestCountInterceptor.ts |  44 +++
 nodejs/src/hook/MessageHookPoints.ts               |  55 ++++
 .../{index.ts => hook/MessageHookPointsStatus.ts}  |  15 +-
 .../ConsumeTask.ts => hook/MessageInterceptor.ts}  |  34 +-
 .../MessageInterceptorContext.ts}                  |  34 +-
 nodejs/src/hook/MessageInterceptorContextImpl.ts   |  68 ++++
 nodejs/src/hook/MessageMeterInterceptor.ts         | 162 ++++++++++
 nodejs/src/{ => hook}/index.ts                     |  17 +-
 nodejs/src/index.ts                                |   1 +
 nodejs/src/metrics/ClientMeter.ts                  |  81 +++++
 nodejs/src/metrics/ClientMeterManager.ts           | 145 +++++++++
 .../{index.ts => metrics/EmptyGaugeObserver.ts}    |  26 +-
 nodejs/src/{index.ts => metrics/GaugeEnum.ts}      |  23 +-
 nodejs/src/{index.ts => metrics/GaugeObserver.ts}  |  22 +-
 nodejs/src/metrics/HistogramEnum.ts                |  69 ++++
 .../src/{index.ts => metrics/InvocationStatus.ts}  |  18 +-
 nodejs/src/metrics/Metric.ts                       |  42 +++
 nodejs/src/{index.ts => metrics/MetricLabels.ts}   |  21 +-
 nodejs/src/{ => metrics}/index.ts                  |  16 +-
 nodejs/src/producer/Producer.ts                    |  43 ++-
 nodejs/test/hook.test.ts                           | 276 ++++++++++++++++
 nodejs/test/metrics.test.ts                        | 352 +++++++++++++++++++++
 39 files changed, 2159 insertions(+), 154 deletions(-)

diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json
index 91bd72f5..547ba624 100644
--- a/nodejs/package-lock.json
+++ b/nodejs/package-lock.json
@@ -11,6 +11,10 @@
       "dependencies": {
         "@grpc/grpc-js": "^1.9.1",
         "@node-rs/crc32": "^1.7.2",
+        "@opentelemetry/api": "^1.9.1",
+        "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.2",
+        "@opentelemetry/resources": "^1.30.1",
+        "@opentelemetry/sdk-metrics": "^1.30.1",
         "address": "^1.2.2",
         "egg-logger": "^3.4.0",
         "google-protobuf": "^3.21.2",
@@ -1271,6 +1275,213 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@opentelemetry/api": {
+      "version": "1.9.1",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz";,
+      "integrity": 
"sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/@opentelemetry/api-logs": {
+      "version": "0.57.2",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz";,
+      "integrity": 
"sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/api": "^1.3.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@opentelemetry/core": {
+      "version": "1.30.1",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz";,
+      "integrity": 
"sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/semantic-conventions": "1.28.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": ">=1.0.0 <1.10.0"
+      }
+    },
+    "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": {
+      "version": "0.57.2",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.57.2.tgz";,
+      "integrity": 
"sha512-r70B8yKR41F0EC443b5CGB4rUaOMm99I5N75QQt6sHKxYDzSEc6gm48Diz1CI1biwa5tDPznpylTrywO/pT7qw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@grpc/grpc-js": "^1.7.1",
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/exporter-metrics-otlp-http": "0.57.2",
+        "@opentelemetry/otlp-exporter-base": "0.57.2",
+        "@opentelemetry/otlp-grpc-exporter-base": "0.57.2",
+        "@opentelemetry/otlp-transformer": "0.57.2",
+        "@opentelemetry/resources": "1.30.1",
+        "@opentelemetry/sdk-metrics": "1.30.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.3.0"
+      }
+    },
+    "node_modules/@opentelemetry/exporter-metrics-otlp-http": {
+      "version": "0.57.2",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.57.2.tgz";,
+      "integrity": 
"sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/otlp-exporter-base": "0.57.2",
+        "@opentelemetry/otlp-transformer": "0.57.2",
+        "@opentelemetry/resources": "1.30.1",
+        "@opentelemetry/sdk-metrics": "1.30.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.3.0"
+      }
+    },
+    "node_modules/@opentelemetry/otlp-exporter-base": {
+      "version": "0.57.2",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.57.2.tgz";,
+      "integrity": 
"sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/otlp-transformer": "0.57.2"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.3.0"
+      }
+    },
+    "node_modules/@opentelemetry/otlp-grpc-exporter-base": {
+      "version": "0.57.2",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.57.2.tgz";,
+      "integrity": 
"sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@grpc/grpc-js": "^1.7.1",
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/otlp-exporter-base": "0.57.2",
+        "@opentelemetry/otlp-transformer": "0.57.2"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.3.0"
+      }
+    },
+    "node_modules/@opentelemetry/otlp-transformer": {
+      "version": "0.57.2",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.57.2.tgz";,
+      "integrity": 
"sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/api-logs": "0.57.2",
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/resources": "1.30.1",
+        "@opentelemetry/sdk-logs": "0.57.2",
+        "@opentelemetry/sdk-metrics": "1.30.1",
+        "@opentelemetry/sdk-trace-base": "1.30.1",
+        "protobufjs": "^7.3.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.3.0"
+      }
+    },
+    "node_modules/@opentelemetry/resources": {
+      "version": "1.30.1",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz";,
+      "integrity": 
"sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/semantic-conventions": "1.28.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": ">=1.0.0 <1.10.0"
+      }
+    },
+    "node_modules/@opentelemetry/sdk-logs": {
+      "version": "0.57.2",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.57.2.tgz";,
+      "integrity": 
"sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/api-logs": "0.57.2",
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/resources": "1.30.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": ">=1.4.0 <1.10.0"
+      }
+    },
+    "node_modules/@opentelemetry/sdk-metrics": {
+      "version": "1.30.1",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz";,
+      "integrity": 
"sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/resources": "1.30.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": ">=1.3.0 <1.10.0"
+      }
+    },
+    "node_modules/@opentelemetry/sdk-trace-base": {
+      "version": "1.30.1",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz";,
+      "integrity": 
"sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@opentelemetry/core": "1.30.1",
+        "@opentelemetry/resources": "1.30.1",
+        "@opentelemetry/semantic-conventions": "1.28.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": ">=1.0.0 <1.10.0"
+      }
+    },
+    "node_modules/@opentelemetry/semantic-conventions": {
+      "version": "1.28.0",
+      "resolved": 
"https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz";,
+      "integrity": 
"sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/@pkgjs/parseargs": {
       "version": "0.11.0",
       "resolved": 
"https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz";,
diff --git a/nodejs/package.json b/nodejs/package.json
index 9e9337f9..96ec1a69 100644
--- a/nodejs/package.json
+++ b/nodejs/package.json
@@ -43,13 +43,17 @@
     "egg-bin": "^6.4.2",
     "eslint": "^8.48.0",
     "eslint-config-egg": "^12.2.1",
-    "grpc-tools": "^1.12.4",
     "grpc_tools_node_protoc_ts": "^5.3.3",
+    "grpc-tools": "^1.12.4",
     "typescript": "^5.2.2"
   },
   "dependencies": {
     "@grpc/grpc-js": "^1.9.1",
     "@node-rs/crc32": "^1.7.2",
+    "@opentelemetry/api": "^1.9.1",
+    "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.2",
+    "@opentelemetry/resources": "^1.30.1",
+    "@opentelemetry/sdk-metrics": "^1.30.1",
     "address": "^1.2.2",
     "egg-logger": "^3.4.0",
     "google-protobuf": "^3.21.2",
diff --git a/nodejs/src/client/BaseClient.ts b/nodejs/src/client/BaseClient.ts
index d2b0e852..d8cdce85 100644
--- a/nodejs/src/client/BaseClient.ts
+++ b/nodejs/src/client/BaseClient.ts
@@ -40,6 +40,13 @@ import {
 import { createResource, getRequestDateTime, sign } from '../util';
 import { TopicRouteData, Endpoints } from '../route';
 import { ClientException, NotFoundException, StatusChecker } from 
'../exception';
+import {
+  CompositedMessageInterceptor,
+  GeneralMessage,
+  MessageInterceptor,
+  MessageInterceptorContext,
+  MessageMeterInterceptor,
+} from '../hook';
 import { Settings } from './Settings';
 import { UserAgent } from './UserAgent';
 import { ILogger, getDefaultLogger } from './Logger';
@@ -47,6 +54,8 @@ import { SessionCredentials } from './SessionCredentials';
 import { RpcClientManager } from './RpcClientManager';
 import { TelemetrySession } from './TelemetrySession';
 import { ClientId } from './ClientId';
+import { ClientMeterManager } from '../metrics';
+import { Metric } from '../metrics/Metric';
 
 const debug = debuglog('rocketmq-client-nodejs:client:BaseClient');
 
@@ -70,6 +79,11 @@ export interface BaseClientOptions {
   requestTimeout?: number;
   logger?: ILogger;
   topics?: string[];
+  /**
+   * Message interceptor(s) invoked around send/receive/consume/ack hook 
points,
+   * mirroring the Java client's MessageInterceptor chain.
+   */
+  messageInterceptor?: MessageInterceptor | MessageInterceptor[];
 }
 
 /**
@@ -95,6 +109,8 @@ export abstract class BaseClient {
   protected readonly logger: ILogger;
   protected readonly rpcClientManager: RpcClientManager;
   readonly #telemetrySessions = new Map<string, TelemetrySession>();
+  readonly #compositedMessageInterceptor = new CompositedMessageInterceptor();
+  protected readonly clientMeterManager: ClientMeterManager;
   #startupResolve?: () => void;
   #startupReject?: (err: Error) => void;
   #timers: NodeJS.Timeout[] = [];
@@ -139,6 +155,12 @@ export abstract class BaseClient {
     // Default request timeout is 3000ms
     this.requestTimeout = options.requestTimeout ?? 3000;
     this.rpcClientManager = new RpcClientManager(this, this.logger);
+    // Wire the metrics pipeline: a dedicated meter manager plus an interceptor
+    // that records the four RocketMQ histograms around the 
SEND/RECEIVE/CONSUME
+    // hook points. Mirrors Java's ClientImpl#initClientMeterManager.
+    this.clientMeterManager = new ClientMeterManager(this.clientId, () => 
this.getRequestMetadata(), this.logger);
+    this.addMessageInterceptor(
+      new MessageMeterInterceptor(this.clientMeterManager, this.clientId, () 
=> this.getConsumerGroup()));
     if (options.topics) {
       for (const topic of options.topics) {
         if (topic && topic.trim().length > 0) {
@@ -146,6 +168,14 @@ export abstract class BaseClient {
         }
       }
     }
+    // Register message interceptors provided by the user (allowed before 
startup only).
+    if (options.messageInterceptor) {
+      const interceptors = Array.isArray(options.messageInterceptor)
+        ? options.messageInterceptor : [ options.messageInterceptor ];
+      for (const interceptor of interceptors) {
+        this.addMessageInterceptor(interceptor);
+      }
+    }
   }
 
   /**
@@ -276,6 +306,10 @@ export abstract class BaseClient {
     // 4. Close RPC connections
     this.rpcClientManager.close();
 
+    // 4.5 Close the metrics export pipeline (flushes pending exports and 
releases
+    // the OTLP/gRPC channel). Mirrors ClientImpl#shutdown's meter manager 
close.
+    await this.clientMeterManager.shutdown();
+
     // 5. Clear caches
     this.topicRouteCache.clear();
     this.inflightRouteFutures.clear();
@@ -513,6 +547,15 @@ export abstract class BaseClient {
     return metadata;
   }
 
+  /**
+   * Consumer group of this client, used as a metric attribute for consumer
+   * histograms/gauges. Producers have no consumer group, so the base
+   * implementation returns undefined; Consumer overrides this.
+   */
+  getConsumerGroup(): string | undefined {
+    return undefined;
+  }
+
   protected abstract getSettings(): Settings;
 
   /**
@@ -561,20 +604,19 @@ export abstract class BaseClient {
   onSettingsCommand(_endpoints: Endpoints, settings: SettingsPB) {
     this.logger.info('Received settings command, clientId=%s, settings=%j',
       this.clientId, settings.toObject());
-    // final Metric metric = new Metric(settings.getMetric());
-    // clientMeterManager.reset(metric);
+    const metric = new Metric(settings.getMetric());
+    this.clientMeterManager.reset(metric);
     this.getSettings().sync(settings);
     this.logger.info('Sync settings=%j, clientId=%s', this.getSettings(), 
this.clientId);
     this.#startupResolve && this.#startupResolve();
   }
 
   onRecoverOrphanedTransactionCommand(_endpoints: Endpoints, command: 
RecoverOrphanedTransactionCommand) {
+    // The base client does not support transactions; subclasses (e.g. 
Producer) override
+    // this method to recover orphaned transactional messages. Mirroring the 
Java client
+    // (ClientImpl#onRecoverOrphanedTransactionCommand), no reply is sent back 
to remote.
     this.logger.warn('Ignore orphaned transaction recovery command from 
remote, which is not expected, clientId=%s, command=%j',
       this.clientId, command.toObject());
-    // const telemetryCommand = new TelemetryCommand();
-    // telemetryCommand.setStatus(new Status().setCode(Code.NOT_IMPLEMENTED));
-    // telemetryCommand.setRecoverOrphanedTransactionCommand(new 
RecoverOrphanedTransactionCommand());
-    // this.telemetry(endpoints, telemetryCommand);
   }
 
   onVerifyMessageCommand(endpoints: Endpoints, command: VerifyMessageCommand) {
@@ -589,6 +631,13 @@ export abstract class BaseClient {
     this.telemetry(endpoints, telemetryCommand);
   }
 
+  /**
+   * Handle the notify-unsubscribe-lite command sent by remote when a lite 
topic
+   * subscription must be dropped (e.g. quota violation or administrative 
action).
+   *
+   * The base client ignores it, mirroring Java 
ClientImpl#onNotifyUnsubscribeLiteCommand;
+   * lite consumers override it to drive their lite subscription manager.
+   */
   onNotifyUnsubscribeLiteCommand(endpoints: Endpoints, command: 
NotifyUnsubscribeLiteCommand) {
     this.logger.warn('Ignore notify unsubscribe lite command from remote, 
which is not expected, clientId=%s, endpoints=%s, command=%j',
       this.clientId, endpoints.facade, command.toObject());
@@ -652,6 +701,42 @@ export abstract class BaseClient {
     return this.endpoints;
   }
 
+  /**
+   * Add a message interceptor to the client. Interceptors can only be 
registered
+   * before the client is running, mirroring Java 
ClientImpl#addMessageInterceptor.
+   */
+  addMessageInterceptor(messageInterceptor: MessageInterceptor) {
+    if (!this.isRunning()) {
+      this.#compositedMessageInterceptor.addInterceptor(messageInterceptor);
+    }
+  }
+
+  /**
+   * Invoke the interceptor chain before a hook point is executed.
+   */
+  doBefore(context: MessageInterceptorContext, generalMessages: 
GeneralMessage[]) {
+    try {
+      this.#compositedMessageInterceptor.doBefore(context, generalMessages);
+    } catch (t) {
+      // Should never reach here, the composite interceptor guards each 
interceptor.
+      this.logger.error('[Bug] Exception raised while handling messages, 
clientId=%s, error=%s',
+        this.clientId, t);
+    }
+  }
+
+  /**
+   * Invoke the interceptor chain after a hook point is executed.
+   */
+  doAfter(context: MessageInterceptorContext, generalMessages: 
GeneralMessage[]) {
+    try {
+      this.#compositedMessageInterceptor.doAfter(context, generalMessages);
+    } catch (t) {
+      // Should never reach here, the composite interceptor guards each 
interceptor.
+      this.logger.error('[Bug] Exception raised while handling messages, 
clientId=%s, error=%s',
+        this.clientId, t);
+    }
+  }
+
   /**
    * Get the RPC client manager.
    *
diff --git a/nodejs/src/consumer/ConsumeService.ts 
b/nodejs/src/consumer/ConsumeService.ts
index a241eaff..c46b2783 100644
--- a/nodejs/src/consumer/ConsumeService.ts
+++ b/nodejs/src/consumer/ConsumeService.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { MessageView } from '../message';
+import { MessageInterceptor } from '../hook';
 import { ConsumeResult } from './ConsumeResult';
 import { ConsumeTask } from './ConsumeTask';
 import { MessageListener } from './MessageListener';
@@ -23,12 +24,17 @@ import type { ProcessQueue } from './ProcessQueue';
 export abstract class ConsumeService {
   protected readonly clientId: string;
   readonly #messageListener: MessageListener;
+  readonly #messageInterceptor?: MessageInterceptor;
+  readonly #consumerGroup?: string;
   #aborted = false;
   #pendingTimers: Set<NodeJS.Timeout> = new Set();
 
-  constructor(clientId: string, messageListener: MessageListener) {
+  constructor(clientId: string, messageListener: MessageListener,
+    messageInterceptor?: MessageInterceptor, consumerGroup?: string) {
     this.clientId = clientId;
     this.#messageListener = messageListener;
+    this.#messageInterceptor = messageInterceptor;
+    this.#consumerGroup = consumerGroup;
   }
 
   abstract consume(pq: ProcessQueue, messageViews: MessageView[]): void;
@@ -37,7 +43,8 @@ export abstract class ConsumeService {
     if (this.#aborted) {
       return ConsumeResult.FAILURE;
     }
-    const task = new ConsumeTask(this.clientId, this.#messageListener, 
messageView);
+    const task = new ConsumeTask(this.clientId, this.#messageListener, 
messageView,
+      this.#messageInterceptor, this.#consumerGroup);
     if (delay <= 0) {
       return this.#executeTask(task);
     }
diff --git a/nodejs/src/consumer/ConsumeTask.ts 
b/nodejs/src/consumer/ConsumeTask.ts
index b502e7f0..ee95151f 100644
--- a/nodejs/src/consumer/ConsumeTask.ts
+++ b/nodejs/src/consumer/ConsumeTask.ts
@@ -16,24 +16,64 @@
  */
 
 import { MessageView } from '../message';
+import {
+  Attribute,
+  AttributeKey,
+  MessageHookPoints,
+  MessageHookPointsStatus,
+  MessageInterceptor,
+  MessageInterceptorContextImpl,
+} from '../hook';
 import { ConsumeResult } from './ConsumeResult';
 import { MessageListener } from './MessageListener';
 
+// Context keys exposed to interceptors, mirroring the Java ConsumeTask keys.
+export const REMOTE_ADDR_CONTEXT_KEY = 
AttributeKey.create<string>('remote_address');
+export const MESSAGE_VIEW_CONTEXT_KEY = 
AttributeKey.create<MessageView>('message_view');
+export const CONSUMER_GROUP_CONTEXT_KEY = 
AttributeKey.create<string>('consumer_group');
+export const CONSUME_ERROR_CONTEXT_KEY = 
AttributeKey.create<unknown>('consume_error');
+
 export class ConsumeTask {
   readonly #messageListener: MessageListener;
   readonly #messageView: MessageView;
+  readonly #consumerGroup?: string;
+  readonly #messageInterceptor?: MessageInterceptor;
 
-  constructor(_clientId: string, messageListener: MessageListener, 
messageView: MessageView) {
+  constructor(_clientId: string, messageListener: MessageListener, 
messageView: MessageView,
+    messageInterceptor?: MessageInterceptor, consumerGroup?: string) {
     this.#messageListener = messageListener;
     this.#messageView = messageView;
+    this.#messageInterceptor = messageInterceptor;
+    this.#consumerGroup = consumerGroup;
   }
 
   async call(): Promise<ConsumeResult> {
+    const generalMessages = [ this.#messageView ];
+    let context = new MessageInterceptorContextImpl(MessageHookPoints.CONSUME);
+    context.putAttribute(MESSAGE_VIEW_CONTEXT_KEY, 
Attribute.create(this.#messageView));
+    if (this.#consumerGroup) {
+      context.putAttribute(CONSUMER_GROUP_CONTEXT_KEY, 
Attribute.create(this.#consumerGroup));
+    }
+    let throwable: unknown;
+    let consumeResult: ConsumeResult;
+    if (this.#messageInterceptor) {
+      this.#messageInterceptor.doBefore(context, generalMessages);
+    }
     try {
-      return await this.#messageListener.consume(this.#messageView);
+      consumeResult = await this.#messageListener.consume(this.#messageView);
     } catch (e) {
       // Message listener raised an exception while consuming messages
-      return ConsumeResult.FAILURE;
+      throwable = e;
+      consumeResult = ConsumeResult.FAILURE;
+    }
+    const status = consumeResult === ConsumeResult.SUCCESS ? 
MessageHookPointsStatus.OK : MessageHookPointsStatus.ERROR;
+    context = MessageInterceptorContextImpl.withStatus(context, status);
+    if (throwable) {
+      context.putAttribute(CONSUME_ERROR_CONTEXT_KEY, 
Attribute.create(throwable));
+    }
+    if (this.#messageInterceptor) {
+      this.#messageInterceptor.doAfter(context, generalMessages);
     }
+    return consumeResult;
   }
 }
diff --git a/nodejs/src/consumer/Consumer.ts b/nodejs/src/consumer/Consumer.ts
index 23a4330a..f312fe4f 100644
--- a/nodejs/src/consumer/Consumer.ts
+++ b/nodejs/src/consumer/Consumer.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { Message, Status } from '../../proto/apache/rocketmq/v2/definition_pb';
+import { Code, Message, Status } from 
'../../proto/apache/rocketmq/v2/definition_pb';
 import {
   AckMessageRequest,
   ChangeInvisibleDurationRequest,
@@ -27,6 +27,12 @@ import { MessageQueue } from '../route';
 import { StatusChecker } from '../exception';
 import { BaseClient, BaseClientOptions } from '../client';
 import { createDuration, createResource } from '../util';
+import {
+  GeneralMessage,
+  MessageHookPoints,
+  MessageHookPointsStatus,
+  MessageInterceptorContextImpl,
+} from '../hook';
 import { FilterExpression } from './FilterExpression';
 
 export interface ConsumerOptions extends BaseClientOptions {
@@ -91,10 +97,18 @@ export abstract class Consumer extends BaseClient {
       }
 
       const messages = messageList.map(message => new MessageView(message, mq, 
transportDeliveryTimestamp));
+      // RECEIVE hook point, mirroring Java ProcessQueueImpl.
+      const context = new 
MessageInterceptorContextImpl(MessageHookPoints.RECEIVE, 
MessageHookPointsStatus.OK);
+      this.doBefore(context, messages);
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), messages);
       return messages;
     } catch (err) {
       this.logger.error('Failed to receive messages, topic=%s, endpoints=%s, 
clientId=%s, error=%s',
         request.getMessageQueue()?.getTopic()?.getName(), endpoints, (this as 
any).clientId, err);
+      // RECEIVE hook point with error status.
+      const context = new 
MessageInterceptorContextImpl(MessageHookPoints.RECEIVE);
+      this.doBefore(context, []);
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), []);
       throw err;
     }
   }
@@ -110,11 +124,23 @@ export abstract class Consumer extends BaseClient {
     if (this.isLiteConsumer() && messageView.liteTopic) {
       entry.setLiteTopic(messageView.liteTopic);
     }
-    const res = await this.rpcClientManager.ackMessage(endpoints, request, 
this.requestTimeout);
-    // FIXME: handle fail ack
-    const response = res.toObject();
-    StatusChecker.check(response.status);
-    return response.entriesList;
+    // ACK hook point, mirroring Java ConsumerImpl#ackMessage.
+    const context = new MessageInterceptorContextImpl(MessageHookPoints.ACK);
+    const generalMessages = [ messageView ];
+    this.doBefore(context, generalMessages);
+    let hookStatus = MessageHookPointsStatus.OK;
+    try {
+      const res = await this.rpcClientManager.ackMessage(endpoints, request, 
this.requestTimeout);
+      // FIXME: handle fail ack
+      const response = res.toObject();
+      hookStatus = response.status?.code === Code.OK ? 
MessageHookPointsStatus.OK : MessageHookPointsStatus.ERROR;
+      StatusChecker.check(response.status);
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
hookStatus), generalMessages);
+      return response.entriesList;
+    } catch (err) {
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), generalMessages);
+      throw err;
+    }
   }
 
   protected async invisibleDuration(messageView: MessageView, 
invisibleDuration: number) {
@@ -128,10 +154,22 @@ export abstract class Consumer extends BaseClient {
       request.setLiteTopic(messageView.liteTopic);
     }
 
-    const res = await 
this.rpcClientManager.changeInvisibleDuration(messageView.endpoints, request, 
this.requestTimeout);
-    const response = res.toObject();
-    StatusChecker.check(response.status);
-    return response.receiptHandle;
+    // CHANGE_INVISIBLE_DURATION hook point, mirroring Java 
ConsumerImpl#changeInvisibleDuration.
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.CHANGE_INVISIBLE_DURATION);
+    const generalMessages = [ messageView ];
+    this.doBefore(context, generalMessages);
+    let hookStatus = MessageHookPointsStatus.OK;
+    try {
+      const res = await 
this.rpcClientManager.changeInvisibleDuration(messageView.endpoints, request, 
this.requestTimeout);
+      const response = res.toObject();
+      hookStatus = response.status?.code === Code.OK ? 
MessageHookPointsStatus.OK : MessageHookPointsStatus.ERROR;
+      StatusChecker.check(response.status);
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
hookStatus), generalMessages);
+      return response.receiptHandle;
+    } catch (err) {
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), generalMessages);
+      throw err;
+    }
   }
 
   /**
@@ -147,9 +185,27 @@ export abstract class Consumer extends BaseClient {
     return res;
   }
 
-  async forwardMessageToDeadLetterQueueViaRpc(endpoints: any, request: any, 
timeout: number) {
-    const res = await 
this.rpcClientManager.forwardMessageToDeadLetterQueue(endpoints, request, 
timeout);
-    return res;
+  /**
+   * Forward the message to the dead letter queue, mirroring Java
+   * PushConsumerImpl#forwardMessageToDeadLetterQueue. The FORWARD_TO_DLQ hook
+   * point is triggered around every RPC attempt.
+   */
+  async forwardMessageToDeadLetterQueueViaRpc(endpoints: any, request: any, 
timeout: number,
+    messageView: MessageView) {
+    // FORWARD_TO_DLQ hook point, mirroring Java 
PushConsumerImpl#forwardMessageToDeadLetterQueue.
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.FORWARD_TO_DLQ);
+    const generalMessages: GeneralMessage[] = [ messageView ];
+    this.doBefore(context, generalMessages);
+    try {
+      const response = await 
this.rpcClientManager.forwardMessageToDeadLetterQueue(endpoints, request, 
timeout);
+      const hookStatus = response.getStatus()?.getCode() === Code.OK ?
+        MessageHookPointsStatus.OK : MessageHookPointsStatus.ERROR;
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
hookStatus), generalMessages);
+      return response;
+    } catch (err) {
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), generalMessages);
+      throw err;
+    }
   }
 
   /**
diff --git a/nodejs/src/consumer/FifoConsumeService.ts 
b/nodejs/src/consumer/FifoConsumeService.ts
index f7bfae74..fe2f7a94 100644
--- a/nodejs/src/consumer/FifoConsumeService.ts
+++ b/nodejs/src/consumer/FifoConsumeService.ts
@@ -16,6 +16,7 @@
  */
 
 import { MessageView } from '../message';
+import { MessageInterceptor } from '../hook';
 import { ConsumeService } from './ConsumeService';
 import { MessageListener } from './MessageListener';
 import type { ProcessQueue } from './ProcessQueue';
@@ -25,8 +26,9 @@ export class FifoConsumeService extends ConsumeService {
   readonly #enableFifoConsumeAccelerator: boolean;
   readonly #logger: ILogger;
 
-  constructor(clientId: string, messageListener: MessageListener, 
enableFifoConsumeAccelerator?: boolean) {
-    super(clientId, messageListener);
+  constructor(clientId: string, messageListener: MessageListener, 
enableFifoConsumeAccelerator?: boolean,
+    messageInterceptor?: MessageInterceptor, consumerGroup?: string) {
+    super(clientId, messageListener, messageInterceptor, consumerGroup);
     this.#enableFifoConsumeAccelerator = enableFifoConsumeAccelerator ?? false;
     this.#logger = getDefaultLogger();
   }
diff --git a/nodejs/src/consumer/LiteFifoConsumeService.ts 
b/nodejs/src/consumer/LiteFifoConsumeService.ts
index 29f39ab3..3923d074 100644
--- a/nodejs/src/consumer/LiteFifoConsumeService.ts
+++ b/nodejs/src/consumer/LiteFifoConsumeService.ts
@@ -16,6 +16,7 @@
  */
 
 import { MessageView } from '../message';
+import { MessageInterceptor } from '../hook';
 import { ConsumeResultSuspend } from './ConsumeResult';
 import { FifoConsumeService } from './FifoConsumeService';
 import { MessageListener } from './MessageListener';
@@ -37,8 +38,9 @@ import { ILogger, getDefaultLogger } from '../client/Logger';
 export class LiteFifoConsumeService extends FifoConsumeService {
   readonly #logger: ILogger;
 
-  constructor(clientId: string, messageListener: MessageListener, 
enableFifoConsumeAccelerator?: boolean) {
-    super(clientId, messageListener, enableFifoConsumeAccelerator);
+  constructor(clientId: string, messageListener: MessageListener, 
enableFifoConsumeAccelerator?: boolean,
+    messageInterceptor?: MessageInterceptor, consumerGroup?: string) {
+    super(clientId, messageListener, enableFifoConsumeAccelerator, 
messageInterceptor, consumerGroup);
     this.#logger = getDefaultLogger();
   }
 
diff --git a/nodejs/src/consumer/LitePushConsumerImpl.ts 
b/nodejs/src/consumer/LitePushConsumerImpl.ts
index 7693cb89..86f8e27f 100644
--- a/nodejs/src/consumer/LitePushConsumerImpl.ts
+++ b/nodejs/src/consumer/LitePushConsumerImpl.ts
@@ -95,10 +95,11 @@ export class LitePushConsumerImpl extends PushConsumer 
implements LitePushConsum
         'Create lite FIFO consume service, consumerGroup=%s, clientId=%s, 
enableFifoConsumeAccelerator=%s',
         this.consumerGroup, this.clientId, this.#enableFifoConsumeAccelerator,
       );
-      return new LiteFifoConsumeService(this.clientId, this.#messageListener, 
this.#enableFifoConsumeAccelerator);
+      return new LiteFifoConsumeService(this.clientId, this.#messageListener, 
this.#enableFifoConsumeAccelerator,
+        this, this.consumerGroup);
     }
     this.logger.info('Create lite standard consume service, consumerGroup=%s, 
clientId=%s', this.consumerGroup, this.clientId);
-    return new LiteStandardConsumeService(this.clientId, 
this.#messageListener);
+    return new LiteStandardConsumeService(this.clientId, 
this.#messageListener, this, this.consumerGroup);
   }
 
   /**
@@ -218,10 +219,12 @@ export class LitePushConsumerImpl extends PushConsumer 
implements LitePushConsum
    * <p>This method is called when the server sends a notification to 
unsubscribe
    * from a lite topic, typically due to quota violations or administrative 
actions.</p>
    *
-   * @param _endpoints - The server endpoints
+   * @param endpoints - The remote endpoints which sent the command
    * @param command - The unsubscribe command from the server
    */
-  onNotifyUnsubscribeLiteCommand(_endpoints: Endpoints, command: 
NotifyUnsubscribeLiteCommand) {
+  onNotifyUnsubscribeLiteCommand(endpoints: Endpoints, command: 
NotifyUnsubscribeLiteCommand) {
+    this.logger.info('Received notify unsubscribe lite command, liteTopic=%s, 
endpoints=%s, clientId=%s',
+      command.getLiteTopic(), endpoints.facade, this.clientId);
     this.liteSubscriptionManager.onNotifyUnsubscribeLiteCommand(command);
   }
 
diff --git a/nodejs/src/consumer/LiteStandardConsumeService.ts 
b/nodejs/src/consumer/LiteStandardConsumeService.ts
index f3d93ced..5f41f1f0 100644
--- a/nodejs/src/consumer/LiteStandardConsumeService.ts
+++ b/nodejs/src/consumer/LiteStandardConsumeService.ts
@@ -16,6 +16,7 @@
  */
 
 import { MessageView } from '../message';
+import { MessageInterceptor } from '../hook';
 import { ConsumeService } from './ConsumeService';
 import { MessageListener } from './MessageListener';
 import type { ProcessQueue } from './ProcessQueue';
@@ -31,8 +32,9 @@ import { ILogger, getDefaultLogger } from '../client/Logger';
 export class LiteStandardConsumeService extends ConsumeService {
   readonly #logger: ILogger;
 
-  constructor(clientId: string, messageListener: MessageListener) {
-    super(clientId, messageListener);
+  constructor(clientId: string, messageListener: MessageListener,
+    messageInterceptor?: MessageInterceptor, consumerGroup?: string) {
+    super(clientId, messageListener, messageInterceptor, consumerGroup);
     this.#logger = getDefaultLogger();
   }
 
diff --git a/nodejs/src/consumer/ProcessQueue.ts 
b/nodejs/src/consumer/ProcessQueue.ts
index e4e2c7fd..6a0fde13 100644
--- a/nodejs/src/consumer/ProcessQueue.ts
+++ b/nodejs/src/consumer/ProcessQueue.ts
@@ -53,6 +53,14 @@ export class ProcessQueue {
     return this.#mq;
   }
 
+  /**
+   * Topic of the underlying message queue, used as a metric attribute when
+   * aggregating the consumer gauges.
+   */
+  get topic(): string {
+    return this.#mq.topic.name;
+  }
+
   drop(): void {
     this.#dropped = true;
   }
@@ -326,6 +334,7 @@ export class ProcessQueue {
         endpoints,
         this.#consumer.wrapForwardMessageToDeadLetterQueueRequest(messageView),
         this.#consumer.requestTimeoutValue,
+        messageView,
       );
       const status = response.getStatus();
       if (!status) {
diff --git a/nodejs/src/consumer/PushConsumer.ts 
b/nodejs/src/consumer/PushConsumer.ts
index f680425c..301d0bc1 100644
--- a/nodejs/src/consumer/PushConsumer.ts
+++ b/nodejs/src/consumer/PushConsumer.ts
@@ -38,9 +38,11 @@ import { ConsumeService } from './ConsumeService';
 import { StandardConsumeService } from './StandardConsumeService';
 import { FifoConsumeService } from './FifoConsumeService';
 import { ProcessQueue } from './ProcessQueue';
+import { PushConsumerGaugeObserver } from './PushConsumerGaugeObserver';
 import { Assignment } from './Assignment';
 import { Assignments } from './Assignments';
 import { MessageListener } from './MessageListener';
+import { InflightRequestCountInterceptor } from '../hook';
 
 const ASSIGNMENT_SCAN_SCHEDULE_DELAY = 1000;
 const ASSIGNMENT_SCAN_SCHEDULE_PERIOD = 5000;
@@ -71,6 +73,7 @@ export class PushConsumer extends Consumer {
   readonly #enableFifoConsumeAccelerator: boolean;
   readonly #processQueueTable = new Map<string /* mq key */, { mq: 
MessageQueue; pq: ProcessQueue }>();
   readonly #metrics = new ConsumeMetrics();
+  readonly #inflightRequestCountInterceptor: InflightRequestCountInterceptor;
   #consumeService!: ConsumeService;
   #scanAssignmentTimer?: NodeJS.Timeout;
   #inflightReceiveRequestCount = 0;
@@ -92,6 +95,16 @@ export class PushConsumer extends Consumer {
     this.#maxCacheMessageSizeInBytes = options.maxCacheMessageSizeInBytes ?? 
64 * 1024 * 1024;
     this.#enableFifoConsumeAccelerator = options.enableFifoConsumeAccelerator 
?? false;
 
+    // Built-in interceptor tracking in-flight receive requests, mirroring 
Java PushConsumerImpl.
+    this.#inflightRequestCountInterceptor = new 
InflightRequestCountInterceptor();
+    this.addMessageInterceptor(this.#inflightRequestCountInterceptor);
+
+    // Register the consumer gauge observer so cached message count/bytes are
+    // exported to the broker. The provider reads the process-queue table 
lazily
+    // at scrape time, so registering here (before any queue exists) is safe.
+    this.clientMeterManager.setGaugeObserver(
+      new PushConsumerGaugeObserver(() => this.processQueues(), this.clientId, 
this.consumerGroup));
+
     this.#pushSubscriptionSettings = new PushSubscriptionSettings(
       options.namespace, this.clientId, this.getClientType(), this.endpoints,
       this.consumerGroup, this.requestTimeout, this.#subscriptionExpressions,
@@ -230,11 +243,22 @@ export class PushConsumer extends Consumer {
     // No-op for push consumer; assignments are queried separately
   }
 
+  /**
+   * Get the count of in-flight receive requests tracked by the built-in
+   * {@link InflightRequestCountInterceptor}, mirroring Java PushConsumerImpl.
+   */
+  protected getInflightReceiveRequestCountFromInterceptor(): number {
+    return 
this.#inflightRequestCountInterceptor.getInflightReceiveRequestCount();
+  }
+
   protected createConsumeService(): ConsumeService {
+    // `this` implements MessageInterceptor (doBefore/doAfter delegating to the
+    // composited interceptor chain), mirroring Java PushConsumerImpl.
     if (this.#pushSubscriptionSettings.isFifo()) {
-      return new FifoConsumeService(this.clientId, this.#messageListener, 
this.#enableFifoConsumeAccelerator);
+      return new FifoConsumeService(this.clientId, this.#messageListener, 
this.#enableFifoConsumeAccelerator,
+        this, this.consumerGroup);
     }
-    return new StandardConsumeService(this.clientId, this.#messageListener);
+    return new StandardConsumeService(this.clientId, this.#messageListener, 
this, this.consumerGroup);
   }
 
   async subscribe(topic: string, filterExpression: FilterExpression) {
@@ -410,6 +434,14 @@ export class PushConsumer extends Consumer {
     return this.#processQueueTable.size;
   }
 
+  /**
+   * All live process queues, used by the consumer gauge observer to aggregate
+   * cached message count/bytes. Reads the private table lazily at scrape time.
+   */
+  processQueues(): ProcessQueue[] {
+    return [ ...this.#processQueueTable.values() ].map(entry => entry.pq);
+  }
+
   cacheMessageBytesThresholdPerQueue(): number {
     const size = this.getQueueSize();
     if (size <= 0) return 0;
diff --git a/nodejs/src/consumer/PushConsumerGaugeObserver.ts 
b/nodejs/src/consumer/PushConsumerGaugeObserver.ts
new file mode 100644
index 00000000..c7a32be2
--- /dev/null
+++ b/nodejs/src/consumer/PushConsumerGaugeObserver.ts
@@ -0,0 +1,60 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Attributes } from '@opentelemetry/api';
+import { ProcessQueue } from './ProcessQueue';
+import { GaugeEnum } from '../metrics/GaugeEnum';
+import { GaugeObserver } from '../metrics/GaugeObserver';
+import { MetricLabels } from '../metrics/MetricLabels';
+
+/**
+ * Aggregates the per-process-queue cache statistics of a push consumer into 
the
+ * two RocketMQ consumer gauges, mirroring the GaugeObserver implementation 
held
+ * by org.apache.rocketmq.client.java.impl.consumer.PushConsumerImpl.
+ */
+export class PushConsumerGaugeObserver implements GaugeObserver {
+  constructor(
+    private readonly processQueuesProvider: () => ProcessQueue[],
+    private readonly clientId: string,
+    private readonly consumerGroup: string,
+  ) {
+  }
+
+  getGauges(): GaugeEnum[] {
+    return [ GaugeEnum.CONSUMER_CACHED_MESSAGES, 
GaugeEnum.CONSUMER_CACHED_BYTES ];
+  }
+
+  getValues(gauge: GaugeEnum): Map<Attributes, number> {
+    const result = new Map<Attributes, number>();
+    const byTopic = new Map<string, number>();
+    for (const pq of this.processQueuesProvider()) {
+      const increment = gauge === GaugeEnum.CONSUMER_CACHED_MESSAGES
+        ? pq.cachedMessagesCount()
+        : pq.cachedMessageBytes();
+      byTopic.set(pq.topic, (byTopic.get(pq.topic) ?? 0) + increment);
+    }
+    for (const [ topic, value ] of byTopic) {
+      const attributes: Attributes = {
+        [MetricLabels.TOPIC]: topic,
+        [MetricLabels.CLIENT_ID]: this.clientId,
+        [MetricLabels.CONSUMER_GROUP]: this.consumerGroup,
+      };
+      result.set(attributes, value);
+    }
+    return result;
+  }
+}
diff --git a/nodejs/src/consumer/StandardConsumeService.ts 
b/nodejs/src/consumer/StandardConsumeService.ts
index bd507902..1a40a754 100644
--- a/nodejs/src/consumer/StandardConsumeService.ts
+++ b/nodejs/src/consumer/StandardConsumeService.ts
@@ -16,6 +16,7 @@
  */
 
 import { MessageView } from '../message';
+import { MessageInterceptor } from '../hook';
 import { ConsumeService } from './ConsumeService';
 import { MessageListener } from './MessageListener';
 import type { ProcessQueue } from './ProcessQueue';
@@ -25,8 +26,9 @@ export class StandardConsumeService extends ConsumeService {
   private readonly logger = getDefaultLogger();
 
   // eslint-disable-next-line @typescript-eslint/no-useless-constructor
-  constructor(clientId: string, messageListener: MessageListener) {
-    super(clientId, messageListener);
+  constructor(clientId: string, messageListener: MessageListener,
+    messageInterceptor?: MessageInterceptor, consumerGroup?: string) {
+    super(clientId, messageListener, messageInterceptor, consumerGroup);
   }
 
   consume(pq: ProcessQueue, messageViews: MessageView[]): void {
diff --git a/nodejs/src/index.ts b/nodejs/src/hook/Attribute.ts
similarity index 67%
copy from nodejs/src/index.ts
copy to nodejs/src/hook/Attribute.ts
index 28ec5f3b..c79e48a8 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/hook/Attribute.ts
@@ -15,10 +15,22 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+/**
+ * Immutable value holder shared through a {@link MessageInterceptorContext},
+ * mirroring org.apache.rocketmq.client.java.hook.Attribute.
+ */
+export class Attribute<T> {
+  readonly #value: T;
+
+  private constructor(value: T) {
+    this.#value = value;
+  }
+
+  static create<T>(value: T): Attribute<T> {
+    return new Attribute(value);
+  }
+
+  get(): T {
+    return this.#value;
+  }
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/hook/AttributeKey.ts
similarity index 57%
copy from nodejs/src/index.ts
copy to nodejs/src/hook/AttributeKey.ts
index 28ec5f3b..64e11b20 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/hook/AttributeKey.ts
@@ -15,10 +15,28 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+/**
+ * Typed key of a context attribute, mirroring
+ * org.apache.rocketmq.client.java.hook.AttributeKey.
+ *
+ * The type parameter is only used for compile-time typing of the associated 
value.
+ */
+export class AttributeKey<T> {
+  readonly name: string;
+  /**
+   * Never assigned at runtime; it only carries the value type for 
compile-time typing.
+   */
+  readonly typeHint?: T;
+
+  constructor(name: string) {
+    this.name = name;
+  }
+
+  static create<T>(name: string): AttributeKey<T> {
+    return new AttributeKey<T>(name);
+  }
+
+  toString() {
+    return `AttributeKey(${this.name})`;
+  }
+}
diff --git a/nodejs/src/hook/CompositedMessageInterceptor.ts 
b/nodejs/src/hook/CompositedMessageInterceptor.ts
new file mode 100644
index 00000000..a884d0bc
--- /dev/null
+++ b/nodejs/src/hook/CompositedMessageInterceptor.ts
@@ -0,0 +1,98 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Attribute } from './Attribute';
+import { AttributeKey } from './AttributeKey';
+import { MessageHookPointsStatus } from './MessageHookPointsStatus';
+import { MessageInterceptorContext } from './MessageInterceptorContext';
+import { MessageInterceptorContextImpl } from 
'./MessageInterceptorContextImpl';
+import { GeneralMessage, MessageInterceptor } from './MessageInterceptor';
+
+// Key under which per-interceptor attributes are stashed between doBefore and 
doAfter.
+const INTERCEPTOR_ATTRIBUTES_KEY = AttributeKey
+  .create<Map<number, Map<AttributeKey<any>, 
Attribute<any>>>>('composited_interceptor_attributes');
+
+/**
+ * Composite interceptor which delegates to a chain of interceptors: doBefore 
runs
+ * in registration order while doAfter runs in reverse order, mirroring
+ * org.apache.rocketmq.client.java.hook.CompositedMessageInterceptor.
+ */
+export class CompositedMessageInterceptor implements MessageInterceptor {
+  readonly #interceptors: MessageInterceptor[] = [];
+
+  constructor(interceptors: MessageInterceptor[] = []) {
+    this.#interceptors.push(...interceptors);
+  }
+
+  addInterceptor(interceptor: MessageInterceptor) {
+    this.#interceptors.push(interceptor);
+  }
+
+  get size() {
+    return this.#interceptors.length;
+  }
+
+  doBefore(context0: MessageInterceptorContext, messages: GeneralMessage[]) {
+    const attributeMap = new Map<number, Map<AttributeKey<any>, 
Attribute<any>>>();
+    for (let index = 0; index < this.#interceptors.length; index++) {
+      const context = new 
MessageInterceptorContextImpl(context0.getMessageHookPoints(), 
context0.getStatus());
+      // Share the attributes accumulated by the caller and previous 
interceptors.
+      for (const [ key, value ] of context0.getAttributes()) {
+        context.putAttribute(key, value);
+      }
+      try {
+        this.#interceptors[index].doBefore(context, messages);
+      } catch (t) {
+        // Interceptors must never break the message pipeline.
+        // eslint-disable-next-line no-console
+        console.error(
+          '[CompositedMessageInterceptor] Exception raised while handling 
messages before hook point=%s, error=%s',
+          context0.getMessageHookPoints(), t);
+      }
+      attributeMap.set(index, context.getAttributes());
+    }
+    context0.putAttribute(INTERCEPTOR_ATTRIBUTES_KEY, 
Attribute.create(attributeMap));
+    // Propagate attributes collected by the first interceptor to the caller 
context.
+    const firstAttributes = attributeMap.get(0);
+    if (firstAttributes) {
+      for (const [ key, value ] of firstAttributes) {
+        context0.putAttribute(key, value);
+      }
+    }
+  }
+
+  doAfter(context0: MessageInterceptorContext, messages: GeneralMessage[]) {
+    const attributeMap = 
context0.getAttribute(INTERCEPTOR_ATTRIBUTES_KEY)?.get()
+      ?? new Map<number, Map<AttributeKey<any>, Attribute<any>>>();
+    for (let index = this.#interceptors.length - 1; index >= 0; index--) {
+      const attributes = attributeMap.get(index);
+      const context = new MessageInterceptorContextImpl(
+        context0.getMessageHookPoints(), context0.getStatus() ?? 
MessageHookPointsStatus.OK, attributes);
+      for (const [ key, value ] of context0.getAttributes()) {
+        context.putAttribute(key, value);
+      }
+      try {
+        this.#interceptors[index].doAfter(context, messages);
+      } catch (t) {
+        // eslint-disable-next-line no-console
+        console.error(
+          '[CompositedMessageInterceptor] Exception raised while handling 
messages after hook point=%s, error=%s',
+          context0.getMessageHookPoints(), t);
+      }
+    }
+  }
+}
diff --git a/nodejs/src/hook/InflightRequestCountInterceptor.ts 
b/nodejs/src/hook/InflightRequestCountInterceptor.ts
new file mode 100644
index 00000000..c508532c
--- /dev/null
+++ b/nodejs/src/hook/InflightRequestCountInterceptor.ts
@@ -0,0 +1,44 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MessageHookPoints } from './MessageHookPoints';
+import { MessageInterceptorContext } from './MessageInterceptorContext';
+import { GeneralMessage, MessageInterceptor } from './MessageInterceptor';
+
+/**
+ * Interceptor which counts the in-flight receive requests, mirroring
+ * org.apache.rocketmq.client.java.hook.InflightRequestCountInterceptor.
+ */
+export class InflightRequestCountInterceptor implements MessageInterceptor {
+  #inflightReceiveRequestCount = 0;
+
+  doBefore(context: MessageInterceptorContext, _messages: GeneralMessage[]) {
+    if (context.getMessageHookPoints() === MessageHookPoints.RECEIVE) {
+      this.#inflightReceiveRequestCount++;
+    }
+  }
+
+  doAfter(context: MessageInterceptorContext, _messages: GeneralMessage[]) {
+    if (context.getMessageHookPoints() === MessageHookPoints.RECEIVE) {
+      this.#inflightReceiveRequestCount--;
+    }
+  }
+
+  getInflightReceiveRequestCount(): number {
+    return this.#inflightReceiveRequestCount;
+  }
+}
diff --git a/nodejs/src/hook/MessageHookPoints.ts 
b/nodejs/src/hook/MessageHookPoints.ts
new file mode 100644
index 00000000..693572ad
--- /dev/null
+++ b/nodejs/src/hook/MessageHookPoints.ts
@@ -0,0 +1,55 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Hook points at which {@link MessageInterceptor}s are invoked, mirroring
+ * org.apache.rocketmq.client.java.hook.MessageHookPoints.
+ */
+export enum MessageHookPoints {
+  /**
+   * The hook point of message publishing.
+   */
+  SEND = 'SEND',
+  /**
+   * The hook point of message reception.
+   */
+  RECEIVE = 'RECEIVE',
+  /**
+   * The hook point of message consumption.
+   */
+  CONSUME = 'CONSUME',
+  /**
+   * The hook point of message ack acknowledgement.
+   */
+  ACK = 'ACK',
+  /**
+   * The hook point of message changing invisible duration.
+   */
+  CHANGE_INVISIBLE_DURATION = 'CHANGE_INVISIBLE_DURATION',
+  /**
+   * The hook point of message transaction commit.
+   */
+  COMMIT_TRANSACTION = 'COMMIT_TRANSACTION',
+  /**
+   * The hook point of message transaction rollback.
+   */
+  ROLLBACK_TRANSACTION = 'ROLLBACK_TRANSACTION',
+  /**
+   * The hook point of forwarding message to DLQ.
+   */
+  FORWARD_TO_DLQ = 'FORWARD_TO_DLQ',
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/hook/MessageHookPointsStatus.ts
similarity index 80%
copy from nodejs/src/index.ts
copy to nodejs/src/hook/MessageHookPointsStatus.ts
index 28ec5f3b..71dba35c 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/hook/MessageHookPointsStatus.ts
@@ -15,10 +15,11 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+/**
+ * Status of a hook point invocation, mirroring
+ * org.apache.rocketmq.client.java.hook.MessageHookPointsStatus.
+ */
+export enum MessageHookPointsStatus {
+  OK = 'OK',
+  ERROR = 'ERROR',
+}
diff --git a/nodejs/src/consumer/ConsumeTask.ts 
b/nodejs/src/hook/MessageInterceptor.ts
similarity index 52%
copy from nodejs/src/consumer/ConsumeTask.ts
copy to nodejs/src/hook/MessageInterceptor.ts
index b502e7f0..92f385a9 100644
--- a/nodejs/src/consumer/ConsumeTask.ts
+++ b/nodejs/src/hook/MessageInterceptor.ts
@@ -15,25 +15,21 @@
  * limitations under the License.
  */
 
-import { MessageView } from '../message';
-import { ConsumeResult } from './ConsumeResult';
-import { MessageListener } from './MessageListener';
+import { MessageView } from '../message/MessageView';
+import { MessageInterceptorContext } from './MessageInterceptorContext';
 
-export class ConsumeTask {
-  readonly #messageListener: MessageListener;
-  readonly #messageView: MessageView;
-
-  constructor(_clientId: string, messageListener: MessageListener, 
messageView: MessageView) {
-    this.#messageListener = messageListener;
-    this.#messageView = messageView;
-  }
+/**
+ * Message passed to interceptors: either an outgoing message or a received 
one,
+ * mirroring org.apache.rocketmq.client.java.message.GeneralMessage.
+ */
+export type GeneralMessage = MessageView | { topic: string };
 
-  async call(): Promise<ConsumeResult> {
-    try {
-      return await this.#messageListener.consume(this.#messageView);
-    } catch (e) {
-      // Message listener raised an exception while consuming messages
-      return ConsumeResult.FAILURE;
-    }
-  }
+/**
+ * Interface for intercepting ingoing/outgoing message before/after they are
+ * dispatched by client, mirroring
+ * org.apache.rocketmq.client.java.hook.MessageInterceptor.
+ */
+export interface MessageInterceptor {
+  doBefore(context: MessageInterceptorContext, messages: GeneralMessage[]): 
void;
+  doAfter(context: MessageInterceptorContext, messages: GeneralMessage[]): 
void;
 }
diff --git a/nodejs/src/consumer/ConsumeTask.ts 
b/nodejs/src/hook/MessageInterceptorContext.ts
similarity index 53%
copy from nodejs/src/consumer/ConsumeTask.ts
copy to nodejs/src/hook/MessageInterceptorContext.ts
index b502e7f0..73bcd820 100644
--- a/nodejs/src/consumer/ConsumeTask.ts
+++ b/nodejs/src/hook/MessageInterceptorContext.ts
@@ -15,25 +15,19 @@
  * limitations under the License.
  */
 
-import { MessageView } from '../message';
-import { ConsumeResult } from './ConsumeResult';
-import { MessageListener } from './MessageListener';
+import { Attribute } from './Attribute';
+import { AttributeKey } from './AttributeKey';
+import { MessageHookPoints } from './MessageHookPoints';
+import { MessageHookPointsStatus } from './MessageHookPointsStatus';
 
-export class ConsumeTask {
-  readonly #messageListener: MessageListener;
-  readonly #messageView: MessageView;
-
-  constructor(_clientId: string, messageListener: MessageListener, 
messageView: MessageView) {
-    this.#messageListener = messageListener;
-    this.#messageView = messageView;
-  }
-
-  async call(): Promise<ConsumeResult> {
-    try {
-      return await this.#messageListener.consume(this.#messageView);
-    } catch (e) {
-      // Message listener raised an exception while consuming messages
-      return ConsumeResult.FAILURE;
-    }
-  }
+/**
+ * Context carried through the message interceptor chain, mirroring
+ * org.apache.rocketmq.client.java.hook.MessageInterceptorContext.
+ */
+export interface MessageInterceptorContext {
+  getMessageHookPoints(): MessageHookPoints;
+  getStatus(): MessageHookPointsStatus | undefined;
+  putAttribute<T>(key: AttributeKey<T>, value: Attribute<T>): void;
+  getAttribute<T>(key: AttributeKey<T>): Attribute<T> | undefined;
+  getAttributes(): Map<AttributeKey<any>, Attribute<any>>;
 }
diff --git a/nodejs/src/hook/MessageInterceptorContextImpl.ts 
b/nodejs/src/hook/MessageInterceptorContextImpl.ts
new file mode 100644
index 00000000..250ce105
--- /dev/null
+++ b/nodejs/src/hook/MessageInterceptorContextImpl.ts
@@ -0,0 +1,68 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Attribute } from './Attribute';
+import { AttributeKey } from './AttributeKey';
+import { MessageHookPoints } from './MessageHookPoints';
+import { MessageHookPointsStatus } from './MessageHookPointsStatus';
+import { MessageInterceptorContext } from './MessageInterceptorContext';
+
+/**
+ * Default {@link MessageInterceptorContext} implementation, mirroring
+ * org.apache.rocketmq.client.java.hook.MessageInterceptorContextImpl.
+ */
+export class MessageInterceptorContextImpl implements 
MessageInterceptorContext {
+  readonly #messageHookPoints: MessageHookPoints;
+  readonly #status: MessageHookPointsStatus | undefined;
+  readonly #attributes: Map<AttributeKey<any>, Attribute<any>>;
+
+  constructor(messageHookPoints: MessageHookPoints,
+    status?: MessageHookPointsStatus,
+    attributes?: Map<AttributeKey<any>, Attribute<any>>) {
+    this.#messageHookPoints = messageHookPoints;
+    this.#status = status;
+    this.#attributes = new Map(attributes ?? []);
+  }
+
+  /**
+   * Derive a new context from an existing one with the given status, e.g. 
once the
+   * outcome of the intercepted operation is known.
+   */
+  static withStatus(context: MessageInterceptorContext, status: 
MessageHookPointsStatus) {
+    return new MessageInterceptorContextImpl(context.getMessageHookPoints(), 
status, context.getAttributes());
+  }
+
+  getMessageHookPoints(): MessageHookPoints {
+    return this.#messageHookPoints;
+  }
+
+  getStatus(): MessageHookPointsStatus | undefined {
+    return this.#status;
+  }
+
+  putAttribute<T>(key: AttributeKey<T>, value: Attribute<T>) {
+    this.#attributes.set(key, value);
+  }
+
+  getAttribute<T>(key: AttributeKey<T>): Attribute<T> | undefined {
+    return this.#attributes.get(key) as Attribute<T> | undefined;
+  }
+
+  getAttributes(): Map<AttributeKey<any>, Attribute<any>> {
+    return this.#attributes;
+  }
+}
diff --git a/nodejs/src/hook/MessageMeterInterceptor.ts 
b/nodejs/src/hook/MessageMeterInterceptor.ts
new file mode 100644
index 00000000..42a59259
--- /dev/null
+++ b/nodejs/src/hook/MessageMeterInterceptor.ts
@@ -0,0 +1,162 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Attributes } from '@opentelemetry/api';
+import { Attribute, AttributeKey, MessageHookPoints, MessageHookPointsStatus, 
MessageInterceptor, MessageInterceptorContext } from './';
+import { GeneralMessage } from './MessageInterceptor';
+import { MetricLabels } from '../metrics/MetricLabels';
+import { InvocationStatus } from '../metrics/InvocationStatus';
+import { HistogramEnum } from '../metrics/HistogramEnum';
+import { ClientMeterManager } from '../metrics/ClientMeterManager';
+
+/**
+ * Minimal message shape required to compute metric attributes / latencies.
+ * Covers both the producer's PublishingMessage (cast to GeneralMessage) and 
the
+ * consumer's MessageView (which implements GeneralMessage).
+ */
+type MetricMessage = {
+  topic: string;
+  transportDeliveryTimestamp?: Date;
+  decodeTimestamp?: Date;
+};
+
+/**
+ * Records the four RocketMQ histograms around the SEND / RECEIVE / CONSUME 
hook
+ * points, mirroring 
org.apache.rocketmq.client.java.metrics.MessageMeterInterceptor.
+ *
+ * Unlike Java (which wires an AuthInterceptor + IpNameResolverFactory onto the
+ * export channel), the Node client signs each export request via a per-call
+ * metadata provider (see {@link ClientMeterManager}), so no auth plumbing is
+ * needed here.
+ */
+export class MessageMeterInterceptor implements MessageInterceptor {
+  static readonly SEND_STOPWATCH_KEY = 
AttributeKey.create<number>('send_stopwatch');
+  static readonly CONSUME_STOPWATCH_KEY = 
AttributeKey.create<number>('consume_stopwatch');
+
+  constructor(
+    private readonly meterManager: ClientMeterManager,
+    private readonly clientId: string,
+    private readonly getConsumerGroup: () => string | undefined,
+  ) {
+  }
+
+  doBefore(context: MessageInterceptorContext, messages: GeneralMessage[]): 
void {
+    if (!this.meterManager.isEnabled()) {
+      return;
+    }
+    switch (context.getMessageHookPoints()) {
+      case MessageHookPoints.SEND:
+        context.putAttribute(MessageMeterInterceptor.SEND_STOPWATCH_KEY, 
Attribute.create(Date.now()));
+        break;
+      case MessageHookPoints.CONSUME: {
+        const consumerGroup = this.getConsumerGroup();
+        if (!consumerGroup) {
+          break;
+        }
+        const message = messages[0] as MetricMessage | undefined;
+        if (!message || !message.decodeTimestamp) {
+          break;
+        }
+        const latency = Date.now() - message.decodeTimestamp.getTime();
+        const attributes = this.#consumerAttributes(message.topic, 
consumerGroup);
+        this.meterManager.record(HistogramEnum.AWAIT_TIME, attributes, 
latency);
+        context.putAttribute(MessageMeterInterceptor.CONSUME_STOPWATCH_KEY, 
Attribute.create(Date.now()));
+        break;
+      }
+      default:
+        break;
+    }
+  }
+
+  doAfter(context: MessageInterceptorContext, messages: GeneralMessage[]): 
void {
+    if (!this.meterManager.isEnabled()) {
+      return;
+    }
+    const status = context.getStatus();
+    const invocationStatus = status === MessageHookPointsStatus.OK ? 
InvocationStatus.SUCCESS : InvocationStatus.FAILURE;
+    switch (context.getMessageHookPoints()) {
+      case MessageHookPoints.SEND: {
+        const attr = 
context.getAttribute(MessageMeterInterceptor.SEND_STOPWATCH_KEY);
+        if (!attr) {
+          break;
+        }
+        const message = messages[0] as MetricMessage | undefined;
+        if (!message) {
+          break;
+        }
+        const cost = Date.now() - attr.get();
+        const attributes: Attributes = {
+          [MetricLabels.TOPIC]: message.topic,
+          [MetricLabels.CLIENT_ID]: this.clientId,
+          [MetricLabels.INVOCATION_STATUS]: invocationStatus.name,
+        };
+        this.meterManager.record(HistogramEnum.SEND_COST_TIME, attributes, 
cost);
+        break;
+      }
+      case MessageHookPoints.RECEIVE: {
+        const consumerGroup = this.getConsumerGroup();
+        if (!consumerGroup) {
+          break;
+        }
+        const message = messages[0] as MetricMessage | undefined;
+        if (!message || !message.transportDeliveryTimestamp) {
+          break;
+        }
+        const latency = Date.now() - 
message.transportDeliveryTimestamp.getTime();
+        if (latency < 0) {
+          break;
+        }
+        const attributes = this.#consumerAttributes(message.topic, 
consumerGroup);
+        this.meterManager.record(HistogramEnum.DELIVERY_LATENCY, attributes, 
latency);
+        break;
+      }
+      case MessageHookPoints.CONSUME: {
+        const consumerGroup = this.getConsumerGroup();
+        if (!consumerGroup) {
+          break;
+        }
+        const attr = 
context.getAttribute(MessageMeterInterceptor.CONSUME_STOPWATCH_KEY);
+        if (!attr) {
+          break;
+        }
+        const message = messages[0] as MetricMessage | undefined;
+        if (!message) {
+          break;
+        }
+        const cost = Date.now() - attr.get();
+        const attributes: Attributes = {
+          [MetricLabels.TOPIC]: message.topic,
+          [MetricLabels.CLIENT_ID]: this.clientId,
+          [MetricLabels.CONSUMER_GROUP]: consumerGroup,
+          [MetricLabels.INVOCATION_STATUS]: invocationStatus.name,
+        };
+        this.meterManager.record(HistogramEnum.PROCESS_TIME, attributes, cost);
+        break;
+      }
+      default:
+        break;
+    }
+  }
+
+  #consumerAttributes(topic: string, consumerGroup: string): Attributes {
+    return {
+      [MetricLabels.TOPIC]: topic,
+      [MetricLabels.CLIENT_ID]: this.clientId,
+      [MetricLabels.CONSUMER_GROUP]: consumerGroup,
+    };
+  }
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/hook/index.ts
similarity index 65%
copy from nodejs/src/index.ts
copy to nodejs/src/hook/index.ts
index 28ec5f3b..225a5e15 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/hook/index.ts
@@ -15,10 +15,13 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+export * from './Attribute';
+export * from './AttributeKey';
+export * from './CompositedMessageInterceptor';
+export * from './InflightRequestCountInterceptor';
+export * from './MessageHookPoints';
+export * from './MessageHookPointsStatus';
+export * from './MessageInterceptor';
+export * from './MessageInterceptorContext';
+export * from './MessageInterceptorContextImpl';
+export * from './MessageMeterInterceptor';
diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts
index 28ec5f3b..32e0b114 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/index.ts
@@ -17,6 +17,7 @@
 
 export * from './consumer';
 export * from './exception';
+export * from './hook';
 export * from './message';
 export * from './producer';
 export * from './retry';
diff --git a/nodejs/src/metrics/ClientMeter.ts 
b/nodejs/src/metrics/ClientMeter.ts
new file mode 100644
index 00000000..3645729d
--- /dev/null
+++ b/nodejs/src/metrics/ClientMeter.ts
@@ -0,0 +1,81 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Attributes, Histogram, Meter } from '@opentelemetry/api';
+import { MeterProvider as SdkMeterProvider } from '@opentelemetry/sdk-metrics';
+import { Endpoints } from '../route';
+import { HistogramEnum } from './HistogramEnum';
+import { Metric } from './Metric';
+
+/**
+ * Wraps an OpenTelemetry {@link Meter} and records the four RocketMQ 
histograms.
+ * When metrics are disabled it is a no-op instance, mirroring
+ * org.apache.rocketmq.client.java.metrics.ClientMeter.
+ */
+export class ClientMeter {
+  readonly enabled: boolean;
+  private readonly meter?: Meter;
+  private readonly endpoints?: Endpoints;
+  private readonly provider?: SdkMeterProvider;
+  private readonly histogramMap = new Map<HistogramEnum, Histogram>();
+
+  constructor(meter: Meter | null, endpoints: Endpoints | null, provider: 
SdkMeterProvider | null) {
+    this.enabled = meter != null && endpoints != null && provider != null;
+    this.meter = meter ?? undefined;
+    this.endpoints = endpoints ?? undefined;
+    this.provider = provider ?? undefined;
+  }
+
+  static disabledInstance(): ClientMeter {
+    return new ClientMeter(null, null, null);
+  }
+
+  record(histogramEnum: HistogramEnum, attributes: Attributes, value: number) {
+    if (!this.enabled || !this.meter) {
+      return;
+    }
+    let histogram = this.histogramMap.get(histogramEnum);
+    if (!histogram) {
+      histogram = this.meter.createHistogram(histogramEnum);
+      this.histogramMap.set(histogramEnum, histogram);
+    }
+    histogram.record(value, attributes);
+  }
+
+  async shutdown() {
+    if (!this.enabled || !this.provider) {
+      return;
+    }
+    try {
+      await this.provider.shutdown();
+    } catch (t) {
+      // best-effort shutdown, ignore
+    }
+  }
+
+  /**
+   * Returns true if the supplied metric switch is already satisfied by this
+   * meter (so a rebuild is unnecessary), mirroring ClientMeter#satisfy.
+   */
+  satisfy(metric: Metric): boolean {
+    if (this.enabled && metric.on && this.endpoints != null && 
metric.endpoints != null
+      && this.endpoints.equals(metric.endpoints)) {
+      return true;
+    }
+    return !this.enabled && !metric.on;
+  }
+}
diff --git a/nodejs/src/metrics/ClientMeterManager.ts 
b/nodejs/src/metrics/ClientMeterManager.ts
new file mode 100644
index 00000000..51c5231f
--- /dev/null
+++ b/nodejs/src/metrics/ClientMeterManager.ts
@@ -0,0 +1,145 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Attributes, Meter } from '@opentelemetry/api';
+import {
+  MeterProvider as SdkMeterProvider,
+  PeriodicExportingMetricReader,
+  View,
+} from '@opentelemetry/sdk-metrics';
+import { Resource } from '@opentelemetry/resources';
+import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
+import { ChannelCredentials, Metadata } from '@grpc/grpc-js';
+import { ILogger } from '../client/Logger';
+import { ClientMeter } from './ClientMeter';
+import { GaugeObserver } from './GaugeObserver';
+import { EmptyGaugeObserver } from './EmptyGaugeObserver';
+import { HistogramEnum, buildHistogramView } from './HistogramEnum';
+import { Metric } from './Metric';
+
+const METRIC_EXPORTER_RPC_TIMEOUT = 5000;
+const METRIC_READER_INTERVAL = 60000;
+const METRIC_INSTRUMENTATION_NAME = 'org.apache.rocketmq.message';
+
+/**
+ * Owns the OpenTelemetry metric pipeline for a single client. On every 
settings
+ * command it lazily (re)builds an SdkMeterProvider that exports the four
+ * RocketMQ histograms plus the consumer gauges to the broker-supplied metric
+ * endpoints over OTLP/gRPC, mirroring
+ * org.apache.rocketmq.client.java.metrics.ClientMeterManager.
+ */
+export class ClientMeterManager {
+  private readonly clientId: string;
+  private readonly metadataProvider: () => Metadata;
+  private readonly logger: ILogger;
+  private clientMeter: ClientMeter;
+  private gaugeObserver: GaugeObserver = EmptyGaugeObserver.EMPTY;
+
+  constructor(clientId: string, metadataProvider: () => Metadata, logger: 
ILogger) {
+    this.clientId = clientId;
+    this.metadataProvider = metadataProvider;
+    this.logger = logger;
+    this.clientMeter = ClientMeter.disabledInstance();
+  }
+
+  setGaugeObserver(gaugeObserver: GaugeObserver) {
+    this.gaugeObserver = gaugeObserver;
+  }
+
+  record(histogramEnum: HistogramEnum, attributes: Attributes, value: number) {
+    this.clientMeter.record(histogramEnum, attributes, value);
+  }
+
+  isEnabled(): boolean {
+    return this.clientMeter.enabled;
+  }
+
+  async shutdown() {
+    await this.clientMeter.shutdown();
+  }
+
+  async reset(metric: Metric): Promise<void> {
+    try {
+      if (this.clientMeter.satisfy(metric)) {
+        this.logger.info('Metric settings is satisfied by the current message 
meter, metric=%j, clientId=%s',
+          metric, this.clientId);
+        return;
+      }
+      if (!metric.on || !metric.endpoints) {
+        this.logger.info('Metric is off, clientId=%s', this.clientId);
+        await this.clientMeter.shutdown();
+        this.clientMeter = ClientMeter.disabledInstance();
+        return;
+      }
+      const endpoints = metric.endpoints;
+      // Use the first address as the OTLP/gRPC target. The exporter's URL is
+      // normalized to "host:port" internally, which gRPC-js resolves through
+      // its default DNS resolver (a single IP works fine; the custom `ip`
+      // resolver is used for the primary RPC channels).
+      const address = endpoints.addressesList[0];
+      const url = `http://${address.host}:${address.port}`;
+      const exporter = new OTLPMetricExporter({
+        url,
+        credentials: ChannelCredentials.createInsecure(),
+        timeoutMillis: METRIC_EXPORTER_RPC_TIMEOUT,
+        // Per-request signing mirrors the Java client's AuthInterceptor. The
+        // d.ts types this field as `Metadata`, but the runtime (and the
+        // exporter's own merge logic in otlp-grpc-exporter-base) expects a
+        // function returning freshly-signed Metadata.
+        metadata: (() => this.metadataProvider()) as unknown as Metadata,
+      });
+
+      const reader = new PeriodicExportingMetricReader({
+        exporter,
+        exportIntervalMillis: METRIC_READER_INTERVAL,
+        exportTimeoutMillis: METRIC_EXPORTER_RPC_TIMEOUT,
+      });
+
+      const views: View[] = [
+        new View(buildHistogramView(HistogramEnum.SEND_COST_TIME)),
+        new View(buildHistogramView(HistogramEnum.DELIVERY_LATENCY)),
+        new View(buildHistogramView(HistogramEnum.AWAIT_TIME)),
+        new View(buildHistogramView(HistogramEnum.PROCESS_TIME)),
+      ];
+
+      const provider: SdkMeterProvider = new SdkMeterProvider({
+        resource: Resource.empty(),
+        readers: [ reader ],
+        views,
+      });
+
+      const meter: Meter = provider.getMeter(METRIC_INSTRUMENTATION_NAME);
+
+      const existedClientMeter = this.clientMeter;
+      this.clientMeter = new ClientMeter(meter, endpoints, provider);
+      await existedClientMeter.shutdown();
+      this.logger.info('Metrics is on, endpoints=%s, clientId=%s', endpoints, 
this.clientId);
+
+      const gauges = this.gaugeObserver.getGauges();
+      for (const gauge of gauges) {
+        meter.createObservableGauge(gauge).addCallback(observableResult => {
+          const values = this.gaugeObserver.getValues(gauge);
+          for (const [ attributes, value ] of values) {
+            observableResult.observe(value, attributes);
+          }
+        });
+      }
+    } catch (t) {
+      this.logger.error('Exception raised when resetting message meter, 
clientId=%s, error=%s', this.clientId, t);
+    }
+  }
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/metrics/EmptyGaugeObserver.ts
similarity index 59%
copy from nodejs/src/index.ts
copy to nodejs/src/metrics/EmptyGaugeObserver.ts
index 28ec5f3b..f9b4ce6b 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/metrics/EmptyGaugeObserver.ts
@@ -15,10 +15,22 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+import { Attributes } from '@opentelemetry/api';
+import { GaugeEnum } from './GaugeEnum';
+import { GaugeObserver } from './GaugeObserver';
+
+/**
+ * Default no-op gauge observer used before a consumer registers a real one,
+ * mirroring org.apache.rocketmq.client.java.metrics.EmptyGaugeObserver.
+ */
+export class EmptyGaugeObserver implements GaugeObserver {
+  static readonly EMPTY: EmptyGaugeObserver = new EmptyGaugeObserver();
+
+  getGauges(): GaugeEnum[] {
+    return [];
+  }
+
+  getValues(): Map<Attributes, number> {
+    return new Map();
+  }
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/metrics/GaugeEnum.ts
similarity index 62%
copy from nodejs/src/index.ts
copy to nodejs/src/metrics/GaugeEnum.ts
index 28ec5f3b..ee3c757f 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/metrics/GaugeEnum.ts
@@ -15,10 +15,19 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+/**
+ * The two consumer gauges exported by the RocketMQ client, mirroring
+ * org.apache.rocketmq.client.java.metrics.GaugeEnum.
+ */
+export enum GaugeEnum {
+  /**
+   * Cached message count of push consumer.
+   * Labels: topic, client_id, consumer_group.
+   */
+  CONSUMER_CACHED_MESSAGES = 'rocketmq_consumer_cached_messages',
+  /**
+   * Cached message bytes of push consumer.
+   * Labels: topic, client_id, consumer_group.
+   */
+  CONSUMER_CACHED_BYTES = 'rocketmq_consumer_cached_bytes',
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/metrics/GaugeObserver.ts
similarity index 61%
copy from nodejs/src/index.ts
copy to nodejs/src/metrics/GaugeObserver.ts
index 28ec5f3b..8ad2ea4e 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/metrics/GaugeObserver.ts
@@ -15,10 +15,18 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+import { Attributes } from '@opentelemetry/api';
+import { GaugeEnum } from './GaugeEnum';
+
+/**
+ * Supplies the live values observed by the periodic metric reader for each
+ * consumer gauge, mirroring 
org.apache.rocketmq.client.java.metrics.GaugeObserver.
+ *
+ * The map key is the attribute set (topic / client_id / consumer_group) and 
the
+ * value is the observed gauge reading.
+ */
+export interface GaugeObserver {
+  getGauges(): GaugeEnum[];
+
+  getValues(gauge: GaugeEnum): Map<Attributes, number>;
+}
diff --git a/nodejs/src/metrics/HistogramEnum.ts 
b/nodejs/src/metrics/HistogramEnum.ts
new file mode 100644
index 00000000..c5de21bb
--- /dev/null
+++ b/nodejs/src/metrics/HistogramEnum.ts
@@ -0,0 +1,69 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ExplicitBucketHistogramAggregation, InstrumentType, ViewOptions } 
from '@opentelemetry/sdk-metrics';
+
+/**
+ * The four histograms exported by the RocketMQ client, mirroring
+ * org.apache.rocketmq.client.java.metrics.HistogramEnum. The bucket
+ * boundaries (in milliseconds) are copied verbatim from the Java client.
+ */
+export enum HistogramEnum {
+  /**
+   * Cost time of successful api calls of message publishing.
+   * Labels: topic, client_id, invocation_status.
+   */
+  SEND_COST_TIME = 'rocketmq_send_cost_time',
+  /**
+   * Latency of message delivery from remote.
+   * Labels: topic, client_id, consumer_group.
+   */
+  DELIVERY_LATENCY = 'rocketmq_delivery_latency',
+  /**
+   * Await time of message consumption.
+   * Labels: topic, client_id, consumer_group.
+   */
+  AWAIT_TIME = 'rocketmq_await_time',
+  /**
+   * Process time of message consumption.
+   * Labels: topic, client_id, consumer_group, invocation_status.
+   */
+  PROCESS_TIME = 'rocketmq_process_time',
+}
+
+/**
+ * Explicit bucket boundaries (milliseconds) for each histogram, identical to
+ * the Java client's Aggregation.explicitBucketHistogram(...) calls.
+ */
+export const HISTOGRAM_BUCKETS: Record<HistogramEnum, number[]> = {
+  [HistogramEnum.SEND_COST_TIME]: [ 1.0, 5.0, 10.0, 20.0, 50.0, 200.0, 500.0 ],
+  [HistogramEnum.DELIVERY_LATENCY]: [ 1.0, 5.0, 10.0, 20.0, 50.0, 200.0, 500.0 
],
+  [HistogramEnum.AWAIT_TIME]: [ 1.0, 5.0, 20.0, 100.0, 1000.0, 5 * 1000.0, 10 
* 1000.0 ],
+  [HistogramEnum.PROCESS_TIME]: [ 1.0, 5.0, 10.0, 100.0, 1000.0, 10 * 1000.0, 
60 * 1000.0 ],
+};
+
+/**
+ * Build the OpenTelemetry view that forces the given histogram onto the exact
+ * explicit-bucket aggregation used by the Java client.
+ */
+export function buildHistogramView(histogramEnum: HistogramEnum): ViewOptions {
+  return {
+    instrumentName: histogramEnum,
+    instrumentType: InstrumentType.HISTOGRAM,
+    aggregation: new 
ExplicitBucketHistogramAggregation(HISTOGRAM_BUCKETS[histogramEnum]),
+  };
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/metrics/InvocationStatus.ts
similarity index 69%
copy from nodejs/src/index.ts
copy to nodejs/src/metrics/InvocationStatus.ts
index 28ec5f3b..f57ca1a2 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/metrics/InvocationStatus.ts
@@ -15,10 +15,14 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+/**
+ * Outcome of an instrumented operation (send / consume), mirroring
+ * org.apache.rocketmq.client.java.metrics.InvocationStatus.
+ */
+export class InvocationStatus {
+  static readonly SUCCESS = new InvocationStatus('success');
+  static readonly FAILURE = new InvocationStatus('failure');
+
+  private constructor(readonly name: string) {
+  }
+}
diff --git a/nodejs/src/metrics/Metric.ts b/nodejs/src/metrics/Metric.ts
new file mode 100644
index 00000000..8eadb580
--- /dev/null
+++ b/nodejs/src/metrics/Metric.ts
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Endpoints } from '../route';
+import { Metric as MetricPB } from 
'../../proto/apache/rocketmq/v2/definition_pb';
+
+/**
+ * Local view of the metric switch received from the broker via the telemetry
+ * settings command, mirroring org.apache.rocketmq.client.java.metrics.Metric.
+ *
+ * Metrics are only considered enabled when both the `on` flag is set AND a
+ * valid endpoints is present.
+ */
+export class Metric {
+  readonly endpoints: Endpoints | null;
+  readonly on: boolean;
+
+  constructor(metric: MetricPB | undefined | null) {
+    if (metric && metric.hasEndpoints() && metric.getOn()) {
+      // Endpoints expects the plain AsObject shape, so unwrap the proto 
message.
+      this.endpoints = new Endpoints(metric.getEndpoints()!.toObject());
+      this.on = true;
+    } else {
+      this.endpoints = null;
+      this.on = false;
+    }
+  }
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/metrics/MetricLabels.ts
similarity index 65%
copy from nodejs/src/index.ts
copy to nodejs/src/metrics/MetricLabels.ts
index 28ec5f3b..44480a1e 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/metrics/MetricLabels.ts
@@ -15,10 +15,17 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+/**
+ * Canonical attribute (label) keys attached to every RocketMQ metric,
+ * mirroring org.apache.rocketmq.client.java.metrics.MetricLabels.
+ */
+export class MetricLabels {
+  static readonly TOPIC = 'topic';
+  static readonly CLIENT_ID = 'client_id';
+  static readonly CONSUMER_GROUP = 'consumer_group';
+  static readonly INVOCATION_STATUS = 'invocation_status';
+
+  private constructor() {
+    // constant holder
+  }
+}
diff --git a/nodejs/src/index.ts b/nodejs/src/metrics/index.ts
similarity index 73%
copy from nodejs/src/index.ts
copy to nodejs/src/metrics/index.ts
index 28ec5f3b..a39d22e2 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/metrics/index.ts
@@ -15,10 +15,12 @@
  * limitations under the License.
  */
 
-export * from './consumer';
-export * from './exception';
-export * from './message';
-export * from './producer';
-export * from './retry';
-export * from './route';
-export * from './client';
+export * from './MetricLabels';
+export * from './InvocationStatus';
+export * from './HistogramEnum';
+export * from './GaugeEnum';
+export * from './GaugeObserver';
+export * from './EmptyGaugeObserver';
+export * from './Metric';
+export * from './ClientMeter';
+export * from './ClientMeterManager';
diff --git a/nodejs/src/producer/Producer.ts b/nodejs/src/producer/Producer.ts
index d582d703..04a9bd4e 100644
--- a/nodejs/src/producer/Producer.ts
+++ b/nodejs/src/producer/Producer.ts
@@ -50,6 +50,12 @@ import { Transaction } from './Transaction';
 import { createResource } from '../util';
 import { RecallReceipt } from './RecallReceipt';
 import { RecallMessageRequest } from 
'../../proto/apache/rocketmq/v2/service_pb';
+import {
+  GeneralMessage,
+  MessageHookPoints,
+  MessageHookPointsStatus,
+  MessageInterceptorContextImpl,
+} from '../hook';
 
 export interface ProducerOptions extends BaseClientOptions {
   topic?: string | string[];
@@ -114,11 +120,25 @@ export class Producer extends BaseClient {
       
.setTopic(createResource(message.topic).setResourceNamespace(this.namespace))
       .setResolution(resolution)
       .setSource(source);
-    const response = await this.rpcClientManager.endTransaction(endpoints, 
request, this.requestTimeout);
-    StatusChecker.check(response.getStatus()?.toObject());
-
-    this.logger.debug?.('End transaction successfully, messageId=%s, 
transactionId=%s, resolution=%s, source=%s, clientId=%s',
-      messageId, transactionId, resolutionStr, sourceStr, this.clientId);
+    // COMMIT_TRANSACTION / ROLLBACK_TRANSACTION hook point, mirroring Java 
ProducerImpl#endTransaction.
+    const messageHookPoints = resolution === TransactionResolution.COMMIT ?
+      MessageHookPoints.COMMIT_TRANSACTION : 
MessageHookPoints.ROLLBACK_TRANSACTION;
+    const context = new MessageInterceptorContextImpl(messageHookPoints);
+    const generalMessages: GeneralMessage[] = [ message ];
+    this.doBefore(context, generalMessages);
+    try {
+      const response = await this.rpcClientManager.endTransaction(endpoints, 
request, this.requestTimeout);
+      const statusObj = response.getStatus()?.toObject();
+      const hookStatus = statusObj?.code === Code.OK ? 
MessageHookPointsStatus.OK : MessageHookPointsStatus.ERROR;
+      StatusChecker.check(statusObj);
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
hookStatus), generalMessages);
+
+      this.logger.debug?.('End transaction successfully, messageId=%s, 
transactionId=%s, resolution=%s, source=%s, clientId=%s',
+        messageId, transactionId, resolutionStr, sourceStr, this.clientId);
+    } catch (err) {
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), generalMessages);
+      throw err;
+    }
   }
 
   async onRecoverOrphanedTransactionCommand(endpoints: Endpoints, command: 
RecoverOrphanedTransactionCommand) {
@@ -241,7 +261,18 @@ export class Producer extends BaseClient {
     // Prepare the candidate message queue(s) for retry-sending in advance.
     const candidates = messageGroup ? [ 
loadBalancer.takeMessageQueueByMessageGroup(messageGroup) ] :
       this.#takeMessageQueues(loadBalancer);
-    return await this.#send0(topic, messageType, candidates, pubMessages, 1);
+    // SEND hook point, mirroring Java ProducerImpl#send.
+    const context = new MessageInterceptorContextImpl(MessageHookPoints.SEND);
+    const generalMessages = pubMessages as unknown as GeneralMessage[];
+    this.doBefore(context, generalMessages);
+    try {
+      const receipts = await this.#send0(topic, messageType, candidates, 
pubMessages, 1);
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), generalMessages);
+      return receipts;
+    } catch (err) {
+      this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), generalMessages);
+      throw err;
+    }
   }
 
   #wrapSendMessageRequest(pubMessages: PublishingMessage[], mq: MessageQueue) {
diff --git a/nodejs/test/hook.test.ts b/nodejs/test/hook.test.ts
new file mode 100644
index 00000000..c6e72039
--- /dev/null
+++ b/nodejs/test/hook.test.ts
@@ -0,0 +1,276 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Tests for the message interceptor / hook framework and the
+ * NOTIFY_UNSUBSCRIBE_LITE_COMMAND telemetry dispatch:
+ *  - Telemetry command dispatch vs Java (J-1)
+ *  - Message interceptor chain (J-3)
+ *  - Transaction hook points (K-2)
+ *  - FORWARD_TO_DLQ hook point (K-3)
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import { Endpoints } from '../src/route';
+import { Producer } from '../src/producer';
+import { Consumer } from '../src/consumer';
+import { Message } from '../src/message';
+import { Settings } from '../src/client';
+import {
+  Attribute,
+  AttributeKey,
+  CompositedMessageInterceptor,
+  InflightRequestCountInterceptor,
+  MessageHookPoints,
+  MessageHookPointsStatus,
+  MessageInterceptor,
+  MessageInterceptorContextImpl,
+} from '../src/hook';
+import { Code, TransactionResolution } from 
'../proto/apache/rocketmq/v2/definition_pb';
+import { Status } from '../proto/apache/rocketmq/v2/definition_pb';
+import {
+  HeartbeatRequest,
+  NotifyClientTerminationRequest,
+} from '../proto/apache/rocketmq/v2/service_pb';
+
+function statusOf(code: Code): Status.AsObject {
+  return { code, message: 'mock message', requestId: '' } as unknown as 
Status.AsObject;
+}
+
+describe('Telemetry command dispatch vs Java (J-1)', () => {
+  it('should dispatch NOTIFY_UNSUBSCRIBE_LITE_COMMAND to the client', () => {
+    const { EventEmitter } = require('node:events');
+    const { TelemetryCommand, NotifyUnsubscribeLiteCommand } = 
require('../proto/apache/rocketmq/v2/service_pb');
+    const { TelemetrySession } = require('../src/client/TelemetrySession');
+
+    const received: Array<{ liteTopic: string; facade: string }> = [];
+    const stream = new EventEmitter();
+    stream.write = () => true;
+    stream.end = () => undefined;
+    stream.removeAllListeners = () => stream;
+
+    const fakeClient = {
+      clientId: 'fake-client-id',
+      createTelemetryStream: () => stream,
+      onNotifyUnsubscribeLiteCommand(endpoints: any, command: any) {
+        received.push({ liteTopic: command.getLiteTopic(), facade: 
endpoints.facade });
+      },
+    };
+    new TelemetrySession(fakeClient, new Endpoints('127.0.0.1:8081'),
+      { info() {}, warn() {}, error() {}, debug() {} });
+
+    const telemetryCommand = new TelemetryCommand();
+    telemetryCommand.setNotifyUnsubscribeLiteCommand(new 
NotifyUnsubscribeLiteCommand().setLiteTopic('lite-topic-1'));
+    stream.emit('data', telemetryCommand);
+
+    assert.strictEqual(received.length, 1);
+    assert.strictEqual(received[0].liteTopic, 'lite-topic-1');
+    assert.strictEqual(received[0].facade, '127.0.0.1:8081');
+  });
+});
+
+describe('Transaction hook points vs Java (K-2)', () => {
+  function createProducer(events: string[]) {
+    const producer = new Producer({
+      endpoints: '127.0.0.1:8081',
+      namespace: '',
+      topics: [ 'TopicTestForTransaction' ],
+      messageInterceptor: {
+        doBefore: context => {
+          
events.push(`before:${MessageHookPoints[context.getMessageHookPoints()]}`);
+        },
+        doAfter: context => {
+          
events.push(`after:${MessageHookPoints[context.getMessageHookPoints()]}:${MessageHookPointsStatus[context.getStatus()]}`);
+        },
+      },
+    } as any);
+    return producer;
+  }
+
+  function stubEndTransaction(producer: Producer, status: Status.AsObject) {
+    (producer as any).rpcClientManager = {
+      endTransaction: async () => ({
+        getStatus: () => ({
+          getCode: () => status.code,
+          toObject: () => status,
+        }),
+      }),
+    };
+  }
+
+  it('should trigger COMMIT_TRANSACTION on commit and ROLLBACK_TRANSACTION on 
rollback', async () => {
+    const commitEvents: string[] = [];
+    const producer = createProducer(commitEvents);
+    stubEndTransaction(producer, statusOf(Code.OK));
+    await producer.endTransaction(new Endpoints('127.0.0.1:8081'),
+      new Message({ topic: 'TopicTestForTransaction', body: 
Buffer.from('body') }),
+      'message-id', 'transaction-id', TransactionResolution.COMMIT);
+    assert.deepStrictEqual(commitEvents, [
+      'before:COMMIT_TRANSACTION', 'after:COMMIT_TRANSACTION:OK',
+    ]);
+
+    const rollbackEvents: string[] = [];
+    const rollbackProducer = createProducer(rollbackEvents);
+    stubEndTransaction(rollbackProducer, statusOf(Code.OK));
+    await rollbackProducer.endTransaction(new Endpoints('127.0.0.1:8081'),
+      new Message({ topic: 'TopicTestForTransaction', body: 
Buffer.from('body') }),
+      'message-id', 'transaction-id', TransactionResolution.ROLLBACK);
+    assert.deepStrictEqual(rollbackEvents, [
+      'before:ROLLBACK_TRANSACTION', 'after:ROLLBACK_TRANSACTION:OK',
+    ]);
+  });
+
+  it('should mark the transaction hook as ERROR when the RPC fails', async () 
=> {
+    const events: string[] = [];
+    const producer = createProducer(events);
+    (producer as any).rpcClientManager = {
+      endTransaction: async () => {
+        throw new Error('end transaction failed');
+      },
+    };
+    await assert.rejects(async () => {
+      await producer.endTransaction(new Endpoints('127.0.0.1:8081'),
+        new Message({ topic: 'TopicTestForTransaction', body: 
Buffer.from('body') }),
+        'message-id', 'transaction-id', TransactionResolution.COMMIT);
+    });
+    assert.deepStrictEqual(events, [
+      'before:COMMIT_TRANSACTION', 'after:COMMIT_TRANSACTION:ERROR',
+    ]);
+  });
+});
+
+describe('FORWARD_TO_DLQ hook point vs Java (K-3)', () => {
+  class HookTestConsumer extends Consumer {
+    protected getSettings(): Settings {
+      return {} as Settings;
+    }
+    protected wrapHeartbeatRequest(): HeartbeatRequest {
+      return new HeartbeatRequest();
+    }
+    protected wrapNotifyClientTerminationRequest(): 
NotifyClientTerminationRequest {
+      return new NotifyClientTerminationRequest();
+    }
+  }
+
+  function createConsumer(events: string[]) {
+    return new HookTestConsumer({
+      endpoints: '127.0.0.1:8081',
+      namespace: '',
+      consumerGroup: 'TestGroup',
+      messageInterceptor: {
+        doBefore: context => {
+          
events.push(`before:${MessageHookPoints[context.getMessageHookPoints()]}`);
+        },
+        doAfter: context => {
+          
events.push(`after:${MessageHookPoints[context.getMessageHookPoints()]}:${MessageHookPointsStatus[context.getStatus()]}`);
+        },
+      },
+    } as any);
+  }
+
+  it('should trigger FORWARD_TO_DLQ with the status carried out of the 
response', async () => {
+    const okEvents: string[] = [];
+    const okConsumer = createConsumer(okEvents);
+    (okConsumer as any).rpcClientManager = {
+      forwardMessageToDeadLetterQueue: async () => ({ getStatus: () => ({ 
getCode: () => Code.OK }) }),
+    };
+    await okConsumer.forwardMessageToDeadLetterQueueViaRpc(new 
Endpoints('127.0.0.1:8081'), {}, 3000,
+      { topic: 'TopicTest' } as any);
+    assert.deepStrictEqual(okEvents, [ 'before:FORWARD_TO_DLQ', 
'after:FORWARD_TO_DLQ:OK' ]);
+
+    const errEvents: string[] = [];
+    const errConsumer = createConsumer(errEvents);
+    (errConsumer as any).rpcClientManager = {
+      forwardMessageToDeadLetterQueue: async () => ({ getStatus: () => ({ 
getCode: () => Code.INTERNAL_ERROR }) }),
+    };
+    await errConsumer.forwardMessageToDeadLetterQueueViaRpc(new 
Endpoints('127.0.0.1:8081'), {}, 3000,
+      { topic: 'TopicTest' } as any);
+    assert.deepStrictEqual(errEvents, [ 'before:FORWARD_TO_DLQ', 
'after:FORWARD_TO_DLQ:ERROR' ]);
+  });
+
+  it('should mark FORWARD_TO_DLQ as ERROR when the RPC throws', async () => {
+    const events: string[] = [];
+    const consumer = createConsumer(events);
+    (consumer as any).rpcClientManager = {
+      forwardMessageToDeadLetterQueue: async () => {
+        throw new Error('forward failed');
+      },
+    };
+    await assert.rejects(async () => {
+      await consumer.forwardMessageToDeadLetterQueueViaRpc(new 
Endpoints('127.0.0.1:8081'), {}, 3000,
+        { topic: 'TopicTest' } as any);
+    });
+    assert.deepStrictEqual(events, [ 'before:FORWARD_TO_DLQ', 
'after:FORWARD_TO_DLQ:ERROR' ]);
+  });
+});
+
+describe('Message interceptor chain (J-3)', () => {
+  it('should run doBefore in order and doAfter in reverse order', () => {
+    const calls: string[] = [];
+    const mk = (name: string): MessageInterceptor => ({
+      doBefore: () => { calls.push('before-' + name); },
+      doAfter: () => { calls.push('after-' + name); },
+    });
+    const composited = new CompositedMessageInterceptor([ mk('a'), mk('b'), 
mk('c') ]);
+    const context = new MessageInterceptorContextImpl(MessageHookPoints.SEND);
+    composited.doBefore(context, []);
+    composited.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), []);
+    assert.deepStrictEqual(calls, [
+      'before-a', 'before-b', 'before-c', 'after-c', 'after-b', 'after-a',
+    ]);
+  });
+
+  it('should not break the pipeline when an interceptor throws', () => {
+    const calls: string[] = [];
+    const composited = new CompositedMessageInterceptor([
+      { doBefore: () => { calls.push('before-throwing'); }, doAfter: () => { 
calls.push('after-throwing'); } },
+      { doBefore: () => { throw new Error('boom'); }, doAfter: () => { throw 
new Error('boom'); } },
+      { doBefore: () => { calls.push('before-ok'); }, doAfter: () => { 
calls.push('after-ok'); } },
+    ]);
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.CONSUME);
+    composited.doBefore(context, []);
+    composited.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), []);
+    assert.deepStrictEqual(calls, [ 'before-throwing', 'before-ok', 
'after-ok', 'after-throwing' ]);
+  });
+
+  it('should carry attributes between doBefore and doAfter', () => {
+    const key = AttributeKey.create<string>('k');
+    let seen: string | undefined;
+    const composited = new CompositedMessageInterceptor([
+      {
+        doBefore: context => { context.putAttribute(key, 
Attribute.create('v')); },
+        doAfter: context => { seen = context.getAttribute(key)?.get(); },
+      },
+    ]);
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.RECEIVE);
+    composited.doBefore(context, []);
+    composited.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), []);
+    assert.strictEqual(seen, 'v');
+  });
+
+  it('should count inflight receive requests via 
InflightRequestCountInterceptor', () => {
+    const interceptor = new InflightRequestCountInterceptor();
+    const composited = new CompositedMessageInterceptor([ interceptor ]);
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.RECEIVE);
+    composited.doBefore(context, []);
+    composited.doBefore(context, []);
+    assert.strictEqual(interceptor.getInflightReceiveRequestCount(), 2);
+    composited.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), []);
+    assert.strictEqual(interceptor.getInflightReceiveRequestCount(), 1);
+  });
+});
diff --git a/nodejs/test/metrics.test.ts b/nodejs/test/metrics.test.ts
new file mode 100644
index 00000000..7a7c7cf4
--- /dev/null
+++ b/nodejs/test/metrics.test.ts
@@ -0,0 +1,352 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Regression tests for the metrics export pipeline, aligned with the Java
+ * client's org.apache.rocketmq.client.java.metrics package:
+ *  - Metric switch parsing (on flag + endpoints)
+ *  - Histogram bucket definitions / OTel view construction
+ *  - ClientMeter / ClientMeterManager enable-disable lifecycle
+ *  - MessageMeterInterceptor recording at the SEND/RECEIVE/CONSUME hook points
+ *  - PushConsumerGaugeObserver aggregation of cached message count/bytes
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import { Metadata } from '@grpc/grpc-js';
+import { Attributes } from '@opentelemetry/api';
+import { InstrumentType } from '@opentelemetry/sdk-metrics';
+import {
+  Endpoints as EndpointsPB,
+  AddressScheme,
+  Metric as MetricPB,
+} from '../proto/apache/rocketmq/v2/definition_pb';
+import { Producer } from '../src/producer';
+import { ProcessQueue } from '../src/consumer/ProcessQueue';
+import { PushConsumerGaugeObserver } from 
'../src/consumer/PushConsumerGaugeObserver';
+import { Metric } from '../src/metrics/Metric';
+import { ClientMeter } from '../src/metrics/ClientMeter';
+import { ClientMeterManager } from '../src/metrics/ClientMeterManager';
+import { EmptyGaugeObserver } from '../src/metrics/EmptyGaugeObserver';
+import { GaugeEnum } from '../src/metrics/GaugeEnum';
+import {
+  HistogramEnum,
+  HISTOGRAM_BUCKETS,
+  buildHistogramView,
+} from '../src/metrics/HistogramEnum';
+import { InvocationStatus } from '../src/metrics/InvocationStatus';
+import { MetricLabels } from '../src/metrics/MetricLabels';
+import {
+  MessageHookPoints,
+  MessageHookPointsStatus,
+  MessageInterceptorContextImpl,
+  MessageMeterInterceptor,
+} from '../src/hook';
+
+const silentLogger = {
+  info() { /* noop */ },
+  warn() { /* noop */ },
+  error() { /* noop */ },
+  debug() { /* noop */ },
+};
+
+function buildMetricPb(on: boolean, host = '127.0.0.1', port = 10811): 
MetricPB {
+  const endpointsPb = new EndpointsPB();
+  endpointsPb.setScheme(AddressScheme.IPV4);
+  endpointsPb.addAddresses().setHost(host).setPort(port);
+  return new MetricPB().setOn(on).setEndpoints(endpointsPb);
+}
+
+/**
+ * Captures every histogram record instead of exporting it, so the interceptor
+ * can be exercised without standing up the OTLP pipeline.
+ */
+class RecordingMeterManager {
+  readonly records: Array<{ histogramEnum: HistogramEnum; attributes: 
Attributes; value: number }> = [];
+  enabled = true;
+
+  isEnabled(): boolean {
+    return this.enabled;
+  }
+
+  record(histogramEnum: HistogramEnum, attributes: Attributes, value: number): 
void {
+    this.records.push({ histogramEnum, attributes, value });
+  }
+
+  setGaugeObserver(): void {
+    // not exercised by the interceptor
+  }
+
+  async shutdown(): Promise<void> {
+    // noop
+  }
+}
+
+function asManager(recorder: RecordingMeterManager): ClientMeterManager {
+  return recorder as unknown as ClientMeterManager;
+}
+
+describe('Metric switch parsing (vs Java metrics.Metric)', () => {
+  it('should be enabled when on=true and endpoints are present', () => {
+    const metric = new Metric(buildMetricPb(true, '127.0.0.1', 10811));
+    assert.strictEqual(metric.on, true);
+    assert.ok(metric.endpoints, 'expected endpoints to be parsed');
+    assert.deepStrictEqual(metric.endpoints!.addressesList, [{ host: 
'127.0.0.1', port: 10811 }]);
+  });
+
+  it('should be disabled when the on flag is false', () => {
+    const metric = new Metric(buildMetricPb(false));
+    assert.strictEqual(metric.on, false);
+    assert.strictEqual(metric.endpoints, null);
+  });
+
+  it('should be disabled when the metric is absent', () => {
+    const metric = new Metric(undefined);
+    assert.strictEqual(metric.on, false);
+    assert.strictEqual(metric.endpoints, null);
+  });
+});
+
+describe('Histogram buckets and views', () => {
+  it('should define explicit buckets for all four histograms', () => {
+    const histograms = Object.values(HistogramEnum);
+    assert.strictEqual(histograms.length, 4);
+    for (const histogram of histograms) {
+      assert.ok(HISTOGRAM_BUCKETS[histogram].length > 0,
+        `expected buckets for ${histogram}`);
+    }
+  });
+
+  it('should build a view targeting the histogram instrument', () => {
+    const view = buildHistogramView(HistogramEnum.SEND_COST_TIME);
+    assert.strictEqual(view.instrumentName, HistogramEnum.SEND_COST_TIME);
+    assert.strictEqual(view.instrumentType, InstrumentType.HISTOGRAM);
+    assert.ok(view.aggregation, 'expected an explicit bucket aggregation');
+  });
+});
+
+describe('ClientMeter lifecycle', () => {
+  it('should be a no-op when disabled', () => {
+    const meter = ClientMeter.disabledInstance();
+    assert.strictEqual(meter.enabled, false);
+    // must not throw even though no underlying meter exists
+    meter.record(HistogramEnum.SEND_COST_TIME, {}, 1);
+  });
+
+  it('should only be satisfied by a matching switch', () => {
+    const meter = ClientMeter.disabledInstance();
+    assert.strictEqual(meter.satisfy(new Metric(undefined)), true);
+    assert.strictEqual(meter.satisfy(new Metric(buildMetricPb(true))), false);
+  });
+});
+
+describe('ClientMeterManager reset', () => {
+  it('should keep metrics disabled while the switch is off', async () => {
+    const manager = new ClientMeterManager('client-1', () => new Metadata(), 
silentLogger);
+    assert.strictEqual(manager.isEnabled(), false);
+    await manager.reset(new Metric(buildMetricPb(false)));
+    assert.strictEqual(manager.isEnabled(), false);
+    // idempotent: a second reset must not throw
+    await manager.reset(new Metric(undefined));
+    assert.strictEqual(manager.isEnabled(), false);
+    await manager.shutdown();
+  });
+
+  it('should build the OTLP pipeline when the switch is on', async () => {
+    const manager = new ClientMeterManager('client-2', () => new Metadata(), 
silentLogger);
+    try {
+      // 127.0.0.1:65535 is a closed port: the pipeline is built eagerly, while
+      // the periodic export (60s) never fires during the test.
+      await manager.reset(new Metric(buildMetricPb(true, '127.0.0.1', 65535)));
+      assert.strictEqual(manager.isEnabled(), true, 'expected the meter to be 
enabled');
+    } finally {
+      await manager.shutdown();
+    }
+  });
+});
+
+describe('MessageMeterInterceptor (SEND/RECEIVE/CONSUME)', () => {
+  it('should record send cost time on the SEND hook point', () => {
+    const recorder = new RecordingMeterManager();
+    const interceptor = new MessageMeterInterceptor(asManager(recorder), 
'client-1', () => undefined);
+    const context = new MessageInterceptorContextImpl(MessageHookPoints.SEND);
+    const message = { topic: 'TopicSend' } as any;
+    interceptor.doBefore(context, [ message ]);
+    interceptor.doAfter(
+      MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), [ message ]);
+
+    assert.strictEqual(recorder.records.length, 1);
+    const record = recorder.records[0];
+    assert.strictEqual(record.histogramEnum, HistogramEnum.SEND_COST_TIME);
+    assert.strictEqual(record.attributes[MetricLabels.TOPIC], 'TopicSend');
+    assert.strictEqual(record.attributes[MetricLabels.CLIENT_ID], 'client-1');
+    assert.strictEqual(record.attributes[MetricLabels.INVOCATION_STATUS], 
InvocationStatus.SUCCESS.name);
+    assert.ok(record.value >= 0, 'expected a non-negative cost');
+  });
+
+  it('should mark the send as failed when the hook status is ERROR', () => {
+    const recorder = new RecordingMeterManager();
+    const interceptor = new MessageMeterInterceptor(asManager(recorder), 
'client-1', () => undefined);
+    const context = new MessageInterceptorContextImpl(MessageHookPoints.SEND);
+    const message = { topic: 'TopicSend' } as any;
+    interceptor.doBefore(context, [ message ]);
+    interceptor.doAfter(
+      MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.ERROR), [ message ]);
+
+    assert.strictEqual(recorder.records.length, 1);
+    
assert.strictEqual(recorder.records[0].attributes[MetricLabels.INVOCATION_STATUS],
+      InvocationStatus.FAILURE.name);
+  });
+
+  it('should record delivery latency on the RECEIVE hook point', () => {
+    const recorder = new RecordingMeterManager();
+    const interceptor = new MessageMeterInterceptor(asManager(recorder), 
'client-1', () => 'group-1');
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.RECEIVE);
+    const message = { topic: 'TopicRecv', transportDeliveryTimestamp: new 
Date(Date.now() - 120) } as any;
+    interceptor.doBefore(context, [ message ]);
+    interceptor.doAfter(
+      MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), [ message ]);
+
+    assert.strictEqual(recorder.records.length, 1);
+    const record = recorder.records[0];
+    assert.strictEqual(record.histogramEnum, HistogramEnum.DELIVERY_LATENCY);
+    assert.strictEqual(record.attributes[MetricLabels.TOPIC], 'TopicRecv');
+    assert.strictEqual(record.attributes[MetricLabels.CONSUMER_GROUP], 
'group-1');
+    // 120ms elapsed between the delivery timestamp and now
+    assert.ok(record.value >= 100 && record.value < 5000, `unexpected latency 
${record.value}`);
+  });
+
+  it('should skip delivery latency when the timestamp is in the future', () => 
{
+    const recorder = new RecordingMeterManager();
+    const interceptor = new MessageMeterInterceptor(asManager(recorder), 
'client-1', () => 'group-1');
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.RECEIVE);
+    const message = { topic: 'TopicRecv', transportDeliveryTimestamp: new 
Date(Date.now() + 60_000) } as any;
+    interceptor.doBefore(context, [ message ]);
+    interceptor.doAfter(
+      MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), [ message ]);
+
+    assert.strictEqual(recorder.records.length, 0, 'negative latency must not 
be recorded');
+  });
+
+  it('should record await time and process time on the CONSUME hook point', () 
=> {
+    const recorder = new RecordingMeterManager();
+    const interceptor = new MessageMeterInterceptor(asManager(recorder), 
'client-1', () => 'group-1');
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.CONSUME);
+    const message = { topic: 'TopicConsume', decodeTimestamp: new 
Date(Date.now() - 200) } as any;
+    interceptor.doBefore(context, [ message ]);
+    interceptor.doAfter(
+      MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), [ message ]);
+
+    assert.strictEqual(recorder.records.length, 2);
+    assert.strictEqual(recorder.records[0].histogramEnum, 
HistogramEnum.AWAIT_TIME);
+    assert.ok(recorder.records[0].value >= 200, `unexpected await time 
${recorder.records[0].value}`);
+    assert.strictEqual(recorder.records[1].histogramEnum, 
HistogramEnum.PROCESS_TIME);
+    
assert.strictEqual(recorder.records[1].attributes[MetricLabels.CONSUMER_GROUP], 
'group-1');
+    
assert.strictEqual(recorder.records[1].attributes[MetricLabels.INVOCATION_STATUS],
+      InvocationStatus.SUCCESS.name);
+  });
+
+  it('should record nothing when metrics are disabled', () => {
+    const recorder = new RecordingMeterManager();
+    recorder.enabled = false;
+    const interceptor = new MessageMeterInterceptor(asManager(recorder), 
'client-1', () => 'group-1');
+    const context = new MessageInterceptorContextImpl(MessageHookPoints.SEND);
+    const message = { topic: 'TopicSend' } as any;
+    interceptor.doBefore(context, [ message ]);
+    interceptor.doAfter(
+      MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), [ message ]);
+    assert.strictEqual(recorder.records.length, 0);
+  });
+
+  it('should record nothing on CONSUME when the client has no consumer group', 
() => {
+    const recorder = new RecordingMeterManager();
+    const interceptor = new MessageMeterInterceptor(asManager(recorder), 
'client-1', () => undefined);
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.CONSUME);
+    const message = { topic: 'TopicConsume', decodeTimestamp: new 
Date(Date.now() - 50) } as any;
+    interceptor.doBefore(context, [ message ]);
+    interceptor.doAfter(
+      MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), [ message ]);
+    assert.strictEqual(recorder.records.length, 0);
+  });
+});
+
+describe('Consumer gauge observers', () => {
+  function fakeProcessQueue(topic: string, count: number, bytes: number): 
ProcessQueue {
+    return {
+      topic,
+      cachedMessagesCount: () => count,
+      cachedMessageBytes: () => bytes,
+    } as unknown as ProcessQueue;
+  }
+
+  function toEntries(values: Map<Attributes, number>) {
+    return Array.from(values.entries())
+      .map(([ attributes, value ]) => ({
+        topic: attributes[MetricLabels.TOPIC],
+        group: attributes[MetricLabels.CONSUMER_GROUP],
+        clientId: attributes[MetricLabels.CLIENT_ID],
+        value,
+      }))
+      .sort((a, b) => String(a.topic).localeCompare(String(b.topic)));
+  }
+
+  it('should aggregate cached messages and bytes per topic', () => {
+    const observer = new PushConsumerGaugeObserver(
+      () => [
+        fakeProcessQueue('TopicA', 3, 300),
+        fakeProcessQueue('TopicA', 2, 200),
+        fakeProcessQueue('TopicB', 5, 500),
+      ],
+      'client-1', 'group-1');
+
+    assert.deepStrictEqual(observer.getGauges(),
+      [ GaugeEnum.CONSUMER_CACHED_MESSAGES, GaugeEnum.CONSUMER_CACHED_BYTES ]);
+
+    
assert.deepStrictEqual(toEntries(observer.getValues(GaugeEnum.CONSUMER_CACHED_MESSAGES)),
 [
+      { topic: 'TopicA', group: 'group-1', clientId: 'client-1', value: 5 },
+      { topic: 'TopicB', group: 'group-1', clientId: 'client-1', value: 5 },
+    ]);
+    
assert.deepStrictEqual(toEntries(observer.getValues(GaugeEnum.CONSUMER_CACHED_BYTES)),
 [
+      { topic: 'TopicA', group: 'group-1', clientId: 'client-1', value: 500 },
+      { topic: 'TopicB', group: 'group-1', clientId: 'client-1', value: 500 },
+    ]);
+  });
+
+  it('should return an empty map when no process queue is assigned', () => {
+    const observer = new PushConsumerGaugeObserver(() => [], 'client-1', 
'group-1');
+    
assert.strictEqual(observer.getValues(GaugeEnum.CONSUMER_CACHED_MESSAGES).size, 
0);
+    
assert.strictEqual(observer.getValues(GaugeEnum.CONSUMER_CACHED_BYTES).size, 0);
+  });
+
+  it('should expose no gauges through the empty observer', () => {
+    assert.deepStrictEqual(EmptyGaugeObserver.EMPTY.getGauges(), []);
+    assert.strictEqual(EmptyGaugeObserver.EMPTY.getValues().size, 0);
+  });
+});
+
+describe('BaseClient metrics wiring', () => {
+  it('should create the meter manager during construction', () => {
+    const producer = new Producer({
+      endpoints: '127.0.0.1:8081',
+      namespace: '',
+      topics: [ 'TopicTest' ],
+    });
+    const manager = (producer as any).clientMeterManager;
+    assert.ok(manager instanceof ClientMeterManager, 'expected a 
ClientMeterManager to be created');
+    assert.strictEqual(manager.isEnabled(), false, 'metrics start disabled 
until the settings command');
+  });
+});

Reply via email to