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 fa490c2a Feat/nodejs message interceptor hooks (#1384)
fa490c2a is described below

commit fa490c2a7a7f81b06ecdd554110b3221d47f6541
Author: zhaohai <[email protected]>
AuthorDate: Fri Oct 9 11:20:45 2026 +0800

    Feat/nodejs message interceptor hooks (#1384)
    
    * 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.
    
    * fix(nodejs): align message interceptor hooks with the Java client
    
    Reviewer feedback on the hook framework: RECEIVE had to be intercepted
    before the RPC so in-flight request counters observe real requests, the
    PushConsumer settlement paths had to fire the same ACK /
    CHANGE_INVISIBLE_DURATION hooks as SimpleConsumer (including retries),
    and the composite interceptor had to keep Java's cumulative context
    semantics so chained interceptors see each other's attributes.
---
 nodejs/src/consumer/Consumer.ts                 |  68 +++---
 nodejs/src/consumer/ProcessQueue.ts             |   2 +
 nodejs/src/hook/CompositedMessageInterceptor.ts |  12 +-
 nodejs/test/hook.test.ts                        | 285 ++++++++++++++++++++++--
 4 files changed, 320 insertions(+), 47 deletions(-)

diff --git a/nodejs/src/consumer/Consumer.ts b/nodejs/src/consumer/Consumer.ts
index f312fe4f..233db92c 100644
--- a/nodejs/src/consumer/Consumer.ts
+++ b/nodejs/src/consumer/Consumer.ts
@@ -64,6 +64,12 @@ export abstract class Consumer extends BaseClient {
     const timeout = this.requestTimeout + awaitDuration;
     let status: Status.AsObject | undefined;
 
+    // RECEIVE hook point, mirroring Java ProcessQueueImpl: intercept before 
the
+    // RPC so that interceptors which count in-flight requests observe the real
+    // request, and carry this same context into doAfter.
+    const context = new 
MessageInterceptorContextImpl(MessageHookPoints.RECEIVE);
+    this.doBefore(context, []);
+
     try {
       this.logger.debug?.('Receiving messages from broker, topic=%s, 
endpoints=%s, batchSize=%d, clientId=%s',
         request.getMessageQueue()?.getTopic()?.getName(), endpoints, 
request.getBatchSize(), (this as any).clientId);
@@ -97,17 +103,11 @@ 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;
     }
@@ -173,33 +173,37 @@ export abstract class Consumer extends BaseClient {
   }
 
   /**
-   * Expose public methods for ProcessQueue to access RPC operations
+   * Expose public methods for ProcessQueue to access RPC operations. Each 
attempt
+   * — including retries driven by ProcessQueue — triggers the matching hook 
point,
+   * so PushConsumer and SimpleConsumer observability stays consistent.
    */
-  async ackMessageViaRpc(endpoints: any, request: AckMessageRequest, timeout: 
number) {
-    const res = await this.rpcClientManager.ackMessage(endpoints, request, 
timeout);
-    return res;
+  async ackMessageViaRpc(endpoints: any, request: AckMessageRequest, timeout: 
number, messageView: MessageView) {
+    return this.#invokeWithHook(
+      MessageHookPoints.ACK,
+      messageView,
+      () => this.rpcClientManager.ackMessage(endpoints, request, timeout),
+    );
   }
 
-  async changeInvisibleDurationViaRpc(endpoints: any, request: 
ChangeInvisibleDurationRequest, timeout: number) {
-    const res = await this.rpcClientManager.changeInvisibleDuration(endpoints, 
request, timeout);
-    return res;
+  async changeInvisibleDurationViaRpc(endpoints: any, request: 
ChangeInvisibleDurationRequest, timeout: number,
+    messageView: MessageView) {
+    return this.#invokeWithHook(
+      MessageHookPoints.CHANGE_INVISIBLE_DURATION,
+      messageView,
+      () => this.rpcClientManager.changeInvisibleDuration(endpoints, request, 
timeout),
+    );
   }
 
-  /**
-   * 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);
+  async #invokeWithHook<T extends { getStatus(): Status | undefined 
}>(hookPoint: MessageHookPoints,
+    messageView: MessageView,
+    rpc: () => Promise<T>): Promise<T> {
+    const context = new MessageInterceptorContextImpl(hookPoint);
     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;
+      const response = await rpc();
+      const hookStatus = response.getStatus()?.getCode() === Code.OK
+        ? MessageHookPointsStatus.OK : MessageHookPointsStatus.ERROR;
       this.doAfter(MessageInterceptorContextImpl.withStatus(context, 
hookStatus), generalMessages);
       return response;
     } catch (err) {
@@ -208,6 +212,20 @@ export abstract class Consumer extends BaseClient {
     }
   }
 
+  /**
+   * 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) {
+    return this.#invokeWithHook(
+      MessageHookPoints.FORWARD_TO_DLQ,
+      messageView,
+      () => this.rpcClientManager.forwardMessageToDeadLetterQueue(endpoints, 
request, timeout),
+    );
+  }
+
   /**
    * Get the consumer group name.
    *
diff --git a/nodejs/src/consumer/ProcessQueue.ts 
b/nodejs/src/consumer/ProcessQueue.ts
index 8563b06b..c485da09 100644
--- a/nodejs/src/consumer/ProcessQueue.ts
+++ b/nodejs/src/consumer/ProcessQueue.ts
@@ -247,6 +247,7 @@ export class ProcessQueue {
         endpoints,
         this.#consumer.wrapAckMessageRequest(messageView),
         this.#consumer.requestTimeoutValue,
+        messageView,
       );
       const status = response.getStatus()?.toObject();
       if (status?.code === Code.INVALID_RECEIPT_HANDLE) {
@@ -284,6 +285,7 @@ export class ProcessQueue {
         endpoints,
         this.#consumer.wrapChangeInvisibleDurationRequest(messageView, 
duration),
         this.#consumer.requestTimeoutValue,
+        messageView,
       );
       const status = response.getStatus()?.toObject();
       if (status?.code === Code.INVALID_RECEIPT_HANDLE) {
diff --git a/nodejs/src/hook/CompositedMessageInterceptor.ts 
b/nodejs/src/hook/CompositedMessageInterceptor.ts
index a884d0bc..1276db60 100644
--- a/nodejs/src/hook/CompositedMessageInterceptor.ts
+++ b/nodejs/src/hook/CompositedMessageInterceptor.ts
@@ -48,9 +48,10 @@ export class CompositedMessageInterceptor implements 
MessageInterceptor {
 
   doBefore(context0: MessageInterceptorContext, messages: GeneralMessage[]) {
     const attributeMap = new Map<number, Map<AttributeKey<any>, 
Attribute<any>>>();
+    // A single cumulative context is shared by the whole chain, mirroring the 
Java
+    // implementation, so that later interceptors observe what earlier ones 
recorded.
+    const context = new 
MessageInterceptorContextImpl(context0.getMessageHookPoints(), 
context0.getStatus());
     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);
       }
@@ -66,13 +67,6 @@ export class CompositedMessageInterceptor implements 
MessageInterceptor {
       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[]) {
diff --git a/nodejs/test/hook.test.ts b/nodejs/test/hook.test.ts
index c6e72039..f9e31b39 100644
--- a/nodejs/test/hook.test.ts
+++ b/nodejs/test/hook.test.ts
@@ -19,16 +19,24 @@
  * Tests for the message interceptor / hook framework and the
  * NOTIFY_UNSUBSCRIBE_LITE_COMMAND telemetry dispatch:
  *  - Telemetry command dispatch vs Java (J-1)
+ *  - RECEIVE hook timing and context reuse (J-2)
  *  - Message interceptor chain (J-3)
  *  - Transaction hook points (K-2)
  *  - FORWARD_TO_DLQ hook point (K-3)
+ *  - PushConsumer settlement hooks (K-4)
  */
 
 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 {
+  ConsumeResult,
+  ConsumeResultSuspend,
+  Consumer,
+  FilterExpression,
+  ProcessQueue,
+} from '../src/consumer';
 import { Message } from '../src/message';
 import { Settings } from '../src/client';
 import {
@@ -46,12 +54,67 @@ import { Status } from 
'../proto/apache/rocketmq/v2/definition_pb';
 import {
   HeartbeatRequest,
   NotifyClientTerminationRequest,
+  ReceiveMessageRequest,
+  ReceiveMessageResponse,
 } from '../proto/apache/rocketmq/v2/service_pb';
 
 function statusOf(code: Code): Status.AsObject {
   return { code, message: 'mock message', requestId: '' } as unknown as 
Status.AsObject;
 }
 
+/**
+ * Minimal response shape accepted by both the hook wrapper (which reads
+ * `getStatus().getCode()`) and ProcessQueue (which reads 
`getStatus().toObject()`).
+ */
+function responseOf(code: Code) {
+  return {
+    getStatus: () => ({
+      getCode: () => code,
+      toObject: () => statusOf(code),
+    }),
+  };
+}
+
+/**
+ * Concrete {@link Consumer} used to exercise the hook points without a broker.
+ */
+class HookTestConsumer extends Consumer {
+  protected getSettings(): Settings {
+    return {} as Settings;
+  }
+  protected wrapHeartbeatRequest(): HeartbeatRequest {
+    return new HeartbeatRequest();
+  }
+  protected wrapNotifyClientTerminationRequest(): 
NotifyClientTerminationRequest {
+    return new NotifyClientTerminationRequest();
+  }
+
+  /** Expose the protected receive entry point used by ProcessQueue. */
+  receiveFrom(mq: any, awaitDuration = 0) {
+    return this.receiveMessage(new ReceiveMessageRequest(), mq, awaitDuration);
+  }
+}
+
+/** Records the hook points as `before:POINT` / `after:POINT:STATUS` strings. 
*/
+function eventRecorder(events: string[], key?: AttributeKey<string>, value?: 
string): MessageInterceptor {
+  return {
+    doBefore: context => {
+      
events.push(`before:${MessageHookPoints[context.getMessageHookPoints()]}`);
+      if (key && value !== undefined) {
+        context.putAttribute(key, Attribute.create(value));
+      }
+    },
+    doAfter: context => {
+      const status = context.getStatus();
+      events.push(`after:${MessageHookPoints[context.getMessageHookPoints()]}:`
+        + `${status === undefined ? 'UNSET' : 
MessageHookPointsStatus[status]}`);
+      if (key) {
+        events.push(`attr:${context.getAttribute(key)?.get()}`);
+      }
+    },
+  };
+}
+
 describe('Telemetry command dispatch vs Java (J-1)', () => {
   it('should dispatch NOTIFY_UNSUBSCRIBE_LITE_COMMAND to the client', () => {
     const { EventEmitter } = require('node:events');
@@ -84,6 +147,72 @@ describe('Telemetry command dispatch vs Java (J-1)', () => {
   });
 });
 
+describe('RECEIVE hook timing vs Java (J-2)', () => {
+  const mq = { broker: { endpoints: new Endpoints('127.0.0.1:8081') } } as any;
+
+  function createConsumer(messageInterceptor: MessageInterceptor | 
MessageInterceptor[]) {
+    return new HookTestConsumer({
+      endpoints: '127.0.0.1:8081',
+      namespace: '',
+      consumerGroup: 'TestGroup',
+      messageInterceptor,
+    } as any);
+  }
+
+  function okReceiveResponse() {
+    return [ new ReceiveMessageResponse().setStatus(new 
Status().setCode(Code.OK)) ];
+  }
+
+  it('should run RECEIVE doBefore before the RPC so in-flight requests are 
observable', async () => {
+    const events: string[] = [];
+    const inflight = new InflightRequestCountInterceptor();
+    const consumer = createConsumer([ eventRecorder(events), inflight ]);
+    let inflightDuringRpc = -1;
+    (consumer as any).rpcClientManager = {
+      receiveMessage: async () => {
+        inflightDuringRpc = inflight.getInflightReceiveRequestCount();
+        events.push('rpc');
+        return okReceiveResponse();
+      },
+    };
+    assert.deepStrictEqual(await consumer.receiveFrom(mq), []);
+    // Before the fix doBefore ran after the RPC, so the counter was always 0 
here.
+    assert.strictEqual(inflightDuringRpc, 1);
+    assert.strictEqual(inflight.getInflightReceiveRequestCount(), 0);
+    assert.deepStrictEqual(events, [ 'before:RECEIVE', 'rpc', 
'after:RECEIVE:OK' ]);
+  });
+
+  it('should carry the doBefore context into doAfter when messages are 
received', async () => {
+    const events: string[] = [];
+    const key = AttributeKey.create<string>('receive-attr');
+    const consumer = createConsumer(eventRecorder(events, key, 
'set-in-before'));
+    (consumer as any).rpcClientManager = {
+      receiveMessage: async () => okReceiveResponse(),
+    };
+    await consumer.receiveFrom(mq);
+    assert.deepStrictEqual(events, [
+      'before:RECEIVE', 'after:RECEIVE:OK', 'attr:set-in-before',
+    ]);
+  });
+
+  it('should reuse the same context and fire once when the RPC fails', async 
() => {
+    const events: string[] = [];
+    const key = AttributeKey.create<string>('receive-attr');
+    const inflight = new InflightRequestCountInterceptor();
+    const consumer = createConsumer([ eventRecorder(events, key, 
'set-in-before'), inflight ]);
+    (consumer as any).rpcClientManager = {
+      receiveMessage: async () => {
+        throw new Error('receive failed');
+      },
+    };
+    await assert.rejects(async () => consumer.receiveFrom(mq));
+    assert.strictEqual(inflight.getInflightReceiveRequestCount(), 0);
+    assert.deepStrictEqual(events, [
+      'before:RECEIVE', 'after:RECEIVE:ERROR', 'attr:set-in-before',
+    ]);
+  });
+});
+
 describe('Transaction hook points vs Java (K-2)', () => {
   function createProducer(events: string[]) {
     const producer = new Producer({
@@ -155,18 +284,6 @@ describe('Transaction hook points vs Java (K-2)', () => {
 });
 
 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',
@@ -219,6 +336,118 @@ describe('FORWARD_TO_DLQ hook point vs Java (K-3)', () => 
{
   });
 });
 
+describe('PushConsumer settlement hooks vs Java (K-4)', () => {
+  const mq = {
+    topic: { name: 'TopicTest' },
+    broker: { name: 'broker-a', endpoints: new Endpoints('127.0.0.1:8081') },
+    queueId: 0,
+  } as any;
+
+  /** PushConsumer-shaped surface that {@link ProcessQueue} relies on for 
settlement. */
+  class FakePushConsumer extends HookTestConsumer {
+    get requestTimeoutValue(): number {
+      return 3000;
+    }
+    wrapAckMessageRequest(): any {
+      return {};
+    }
+    wrapChangeInvisibleDurationRequest(): any {
+      return {};
+    }
+    getRetryPolicy() {
+      return { getMaxAttempts: () => 1, getNextAttemptDelay: () => 0 };
+    }
+    getPushConsumerSettings() {
+      // ProcessQueue reads the semaphore bound, long-polling timeout and 
receive
+      // batch size from the subscription settings; the defaults mirror
+      // PushSubscriptionSettings.
+      return {
+        getConsumeConcurrentlyMax: () => 32,
+        getLongPollingTimeout: () => 30000,
+        getReceiveBatchSize: () => 32,
+      };
+    }
+    incrementConsumptionOkQuantity(): void { /* not collected in tests */ }
+    incrementConsumptionErrorQuantity(): void { /* not collected in tests */ }
+    protected isLiteConsumer(): boolean {
+      // Only lite consumers keep a ConsumeResultSuspend, which drives the
+      // CHANGE_INVISIBLE_DURATION settlement path.
+      return true;
+    }
+  }
+
+  function createConsumer(events: string[]) {
+    return new FakePushConsumer({
+      endpoints: '127.0.0.1:8081',
+      namespace: '',
+      consumerGroup: 'TestGroup',
+      messageInterceptor: eventRecorder(events),
+    } as any);
+  }
+
+  function messageView() {
+    return {
+      endpoints: new Endpoints('127.0.0.1:8081'),
+      topic: 'TopicTest',
+      messageId: '010000000000000001',
+      deliveryAttempt: 1,
+      body: Buffer.from('body'),
+      incrementAndGetDeliveryAttempt: () => 2,
+    } as any;
+  }
+
+  it('should fire the ACK hook from ProcessQueue once per attempt, including 
retries', async () => {
+    const events: string[] = [];
+    const consumer = createConsumer(events);
+    let attempts = 0;
+    (consumer as any).rpcClientManager = {
+      ackMessage: async () => {
+        attempts++;
+        events.push('rpc');
+        // The first attempt asks for a retry, which sleeps for the ack 
backoff.
+        return responseOf(attempts === 1 ? Code.INTERNAL_ERROR : Code.OK);
+      },
+    };
+    const processQueue = new ProcessQueue(consumer as any, mq, new 
FilterExpression('*'));
+    await processQueue.eraseFifoMessage(messageView(), ConsumeResult.SUCCESS);
+    assert.strictEqual(attempts, 2);
+    assert.deepStrictEqual(events, [
+      'before:ACK', 'rpc', 'after:ACK:ERROR',
+      'before:ACK', 'rpc', 'after:ACK:OK',
+    ]);
+  });
+
+  it('should fire the CHANGE_INVISIBLE_DURATION hook when consumption is 
suspended', async () => {
+    const events: string[] = [];
+    const consumer = createConsumer(events);
+    (consumer as any).rpcClientManager = {
+      changeInvisibleDuration: async () => {
+        events.push('rpc');
+        return responseOf(Code.OK);
+      },
+    };
+    const processQueue = new ProcessQueue(consumer as any, mq, new 
FilterExpression('*'));
+    await processQueue.eraseFifoMessage(messageView(), 
ConsumeResultSuspend.of(50));
+    assert.deepStrictEqual(events, [
+      'before:CHANGE_INVISIBLE_DURATION', 'rpc', 
'after:CHANGE_INVISIBLE_DURATION:OK',
+    ]);
+  });
+
+  it('should mark the settlement hook as ERROR when the RPC throws', async () 
=> {
+    const events: string[] = [];
+    const consumer = createConsumer(events);
+    (consumer as any).rpcClientManager = {
+      ackMessage: async () => {
+        throw new Error('ack failed');
+      },
+    };
+    await assert.rejects(async () => {
+      await consumer.ackMessageViaRpc(new Endpoints('127.0.0.1:8081'), {} as 
any, 3000, messageView());
+    });
+    assert.deepStrictEqual(events, [ 'before:ACK', 'after:ACK:ERROR' ]);
+  });
+});
+
 describe('Message interceptor chain (J-3)', () => {
   it('should run doBefore in order and doAfter in reverse order', () => {
     const calls: string[] = [];
@@ -263,6 +492,36 @@ describe('Message interceptor chain (J-3)', () => {
     assert.strictEqual(seen, 'v');
   });
 
+  it('should accumulate attributes across interceptors in doBefore', () => {
+    const first = AttributeKey.create<string>('first');
+    const second = AttributeKey.create<string>('second');
+    const seen: Array<Array<string | undefined>> = [];
+    const afterSeen: Array<Array<string | undefined>> = [];
+    const mk = (put?: AttributeKey<string>, value?: string): 
MessageInterceptor => ({
+      doBefore: context => {
+        seen.push([ context.getAttribute(first)?.get(), 
context.getAttribute(second)?.get() ]);
+        if (put && value !== undefined) {
+          context.putAttribute(put, Attribute.create(value));
+        }
+      },
+      doAfter: context => {
+        afterSeen.push([ context.getAttribute(first)?.get(), 
context.getAttribute(second)?.get() ]);
+      },
+    });
+    const composited = new CompositedMessageInterceptor([
+      mk(first, 'v1'),
+      mk(second, 'v2'),
+      mk(),
+    ]);
+    const context = new MessageInterceptorContextImpl(MessageHookPoints.SEND);
+    composited.doBefore(context, []);
+    // Each interceptor must observe everything the previous ones recorded.
+    assert.deepStrictEqual(seen, [[ undefined, undefined ], [ 'v1', undefined 
], [ 'v1', 'v2' ]]);
+    composited.doAfter(MessageInterceptorContextImpl.withStatus(context, 
MessageHookPointsStatus.OK), []);
+    // doAfter runs in reverse order and keeps the accumulated attributes.
+    assert.deepStrictEqual(afterSeen, [[ 'v1', 'v2' ], [ 'v1', 'v2' ], [ 'v1', 
'v2' ]]);
+  });
+
   it('should count inflight receive requests via 
InflightRequestCountInterceptor', () => {
     const interceptor = new InflightRequestCountInterceptor();
     const composited = new CompositedMessageInterceptor([ interceptor ]);

Reply via email to