@drarzter/kafka-client 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -620,7 +620,7 @@ await kafka.startBatchConsumer(
620
620
  | Property/Method | Description |
621
621
  | --------------- | ----------- |
622
622
  | `partition` | Partition number for this batch |
623
- | `highWatermark` | Latest offset in the partition (lag indicator) |
623
+ | `highWatermark` | Latest offset in the partition (`string`). `null` when the message is replayed via a retry topic consumer — in that path the broker high-watermark is not available. Guard against `null` before computing lag |
624
624
  | `heartbeat()` | Send a heartbeat to keep the consumer session alive — call during long processing loops |
625
625
  | `resolveOffset(offset)` | Mark offset as processed (required before `commitOffsetsIfNecessary`) |
626
626
  | `commitOffsetsIfNecessary()` | Commit resolved offsets; respects `autoCommit` setting |
@@ -743,6 +743,47 @@ type BeforeConsumeResult =
743
743
 
744
744
  When multiple instrumentations each provide a `wrap`, they compose in declaration order — the first instrumentation's `wrap` is the outermost.
745
745
 
746
+ ### Lifecycle event hooks
747
+
748
+ Three additional hooks fire for specific events in the consume pipeline:
749
+
750
+ | Hook | When called | Arguments |
751
+ | ---- | ----------- | --------- |
752
+ | `onMessage` | Handler successfully processed a message | `(envelope)` — use as a success counter for error-rate calculations |
753
+ | `onRetry` | A message is queued for another attempt (in-process backoff or routed to a retry topic) | `(envelope, attempt, maxRetries)` |
754
+ | `onDlq` | A message is routed to the dead letter queue | `(envelope, reason)` — reason is `'handler-error'`, `'validation-error'`, or `'lamport-clock-duplicate'` |
755
+ | `onDuplicate` | A duplicate is detected via Lamport Clock | `(envelope, strategy)` — strategy is `'drop'`, `'dlq'`, or `'topic'` |
756
+
757
+ ```typescript
758
+ const myInstrumentation: KafkaInstrumentation = {
759
+ onMessage(envelope) {
760
+ metrics.increment('kafka.processed', { topic: envelope.topic });
761
+ },
762
+ onRetry(envelope, attempt, maxRetries) {
763
+ console.warn(`Retrying ${envelope.topic} — attempt ${attempt}/${maxRetries}`);
764
+ },
765
+ onDlq(envelope, reason) {
766
+ alertingSystem.send({ topic: envelope.topic, reason });
767
+ },
768
+ onDuplicate(envelope, strategy) {
769
+ metrics.increment('kafka.duplicate', { topic: envelope.topic, strategy });
770
+ },
771
+ };
772
+ ```
773
+
774
+ ### Built-in metrics
775
+
776
+ `KafkaClient` maintains lightweight in-process event counters independently of any instrumentation:
777
+
778
+ ```typescript
779
+ const snapshot = kafka.getMetrics();
780
+ // { processedCount: number; retryCount: number; dlqCount: number; dedupCount: number }
781
+
782
+ kafka.resetMetrics(); // reset all counters to zero
783
+ ```
784
+
785
+ Counters are incremented in the same code paths that fire the corresponding hooks — they are always active regardless of whether any instrumentation is configured.
786
+
746
787
  ## Options reference
747
788
 
748
789
  ### Send options
@@ -795,6 +836,7 @@ Passed to `KafkaModule.register()` or returned from `registerAsync()` factory:
795
836
  | `numPartitions` | `1` | Number of partitions for auto-created topics |
796
837
  | `strictSchemas` | `true` | Validate string topic keys against schemas registered via TopicDescriptor |
797
838
  | `instrumentation` | `[]` | Client-wide instrumentation hooks (e.g. OTel). Applied to both send and consume paths |
839
+ | `transactionalId` | `${clientId}-tx` | Transactional producer ID for `transaction()` calls. Must be unique per producer instance across the cluster — two instances sharing the same ID will be fenced by Kafka. The client logs a warning when the same ID is registered twice within one process |
798
840
  | `onMessageLost` | — | Called when a message is silently dropped without DLQ — use to alert, log to external systems, or trigger fallback logic |
799
841
  | `onRebalance` | — | Called on every partition assign/revoke event across all consumers created by this client |
800
842
 
@@ -318,7 +318,10 @@ async function sendToDlq(topic2, rawMessage, deps, meta) {
318
318
  deps.logger.warn(`Message sent to DLQ: ${payload.topic}`);
319
319
  } catch (error) {
320
320
  const err = toError(error);
321
- deps.logger.error(`Failed to send message to DLQ ${payload.topic}:`, err.stack);
321
+ deps.logger.error(
322
+ `Failed to send message to DLQ ${payload.topic}:`,
323
+ err.stack
324
+ );
322
325
  await deps.onMessageLost?.({
323
326
  topic: topic2,
324
327
  error: err,
@@ -333,14 +336,9 @@ var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
333
336
  var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
334
337
  function buildRetryTopicPayload(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders) {
335
338
  const retryTopic = `${originalTopic}.retry.${attempt}`;
339
+ const STRIP = /* @__PURE__ */ new Set([RETRY_HEADER_ATTEMPT, RETRY_HEADER_AFTER, RETRY_HEADER_MAX_RETRIES, RETRY_HEADER_ORIGINAL_TOPIC]);
336
340
  function buildHeaders(hdr) {
337
- const {
338
- [RETRY_HEADER_ATTEMPT]: _a,
339
- [RETRY_HEADER_AFTER]: _b,
340
- [RETRY_HEADER_MAX_RETRIES]: _c,
341
- [RETRY_HEADER_ORIGINAL_TOPIC]: _d,
342
- ...userHeaders
343
- } = hdr;
341
+ const userHeaders = Object.fromEntries(Object.entries(hdr).filter(([k]) => !STRIP.has(k)));
344
342
  return {
345
343
  ...userHeaders,
346
344
  [RETRY_HEADER_ATTEMPT]: String(attempt),
@@ -396,10 +394,18 @@ function buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, m
396
394
  "x-duplicate-incoming-clock": String(meta?.incomingClock ?? 0),
397
395
  "x-duplicate-last-processed-clock": String(meta?.lastProcessedClock ?? 0)
398
396
  };
399
- return { topic: destinationTopic, messages: [{ value: rawMessage, headers }] };
397
+ return {
398
+ topic: destinationTopic,
399
+ messages: [{ value: rawMessage, headers }]
400
+ };
400
401
  }
401
402
  async function sendToDuplicatesTopic(sourceTopic, rawMessage, destinationTopic, deps, meta) {
402
- const payload = buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, meta);
403
+ const payload = buildDuplicateTopicPayload(
404
+ sourceTopic,
405
+ rawMessage,
406
+ destinationTopic,
407
+ meta
408
+ );
403
409
  try {
404
410
  await deps.producer.send(payload);
405
411
  deps.logger.warn(`Duplicate message forwarded to ${destinationTopic}`);
@@ -491,7 +497,10 @@ async function executeWithRetry(fn, ctx, deps) {
491
497
  interceptors,
492
498
  deps.instrumentation
493
499
  );
494
- if (!error) return;
500
+ if (!error) {
501
+ for (const env of envelopes) deps.onMessage?.(env);
502
+ return;
503
+ }
495
504
  const isLastAttempt = attempt === maxAttempts;
496
505
  const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
497
506
  topic2,
@@ -516,6 +525,7 @@ async function executeWithRetry(fn, ctx, deps) {
516
525
  isBatch ? envelopes.map((e) => e.headers) : envelopes[0]?.headers ?? {},
517
526
  deps
518
527
  );
528
+ deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
519
529
  } else if (isLastAttempt) {
520
530
  if (dlq) {
521
531
  for (let i = 0; i < rawMessages.length; i++) {
@@ -524,6 +534,7 @@ async function executeWithRetry(fn, ctx, deps) {
524
534
  attempt,
525
535
  originalHeaders: envelopes[i]?.headers
526
536
  });
537
+ deps.onDlq?.(envelopes[i] ?? envelopes[0], "handler-error");
527
538
  }
528
539
  } else {
529
540
  await deps.onMessageLost?.({
@@ -535,6 +546,7 @@ async function executeWithRetry(fn, ctx, deps) {
535
546
  }
536
547
  } else {
537
548
  const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
549
+ deps.onRetry?.(envelopes[0], attempt, maxAttempts - 1);
538
550
  await sleep(Math.floor(Math.random() * cap));
539
551
  }
540
552
  }
@@ -558,6 +570,7 @@ async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
558
570
  deps.logger.warn(
559
571
  `Duplicate message on ${envelope.topic}[${envelope.partition}]: clock=${incomingClock} <= last=${lastProcessedClock} \u2014 strategy=${strategy}`
560
572
  );
573
+ deps.onDuplicate?.(envelope, strategy);
561
574
  if (strategy === "dlq" && dlq) {
562
575
  const augmentedHeaders = {
563
576
  ...envelope.headers,
@@ -761,6 +774,9 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
761
774
  producer,
762
775
  instrumentation,
763
776
  onMessageLost,
777
+ onRetry,
778
+ onDlq,
779
+ onMessage,
764
780
  ensureTopic,
765
781
  getOrCreateConsumer: getOrCreateConsumer2,
766
782
  runningConsumers,
@@ -842,6 +858,7 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
842
858
  instrumentation
843
859
  );
844
860
  if (!error) {
861
+ onMessage?.(envelope);
845
862
  await consumer.commitOffsets([nextOffset]);
846
863
  return;
847
864
  }
@@ -874,12 +891,23 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
874
891
  await tx.send({ topic: rtTopic, messages: rtMsgs });
875
892
  await tx.sendOffsets({
876
893
  consumer,
877
- topics: [{ topic: nextOffset.topic, partitions: [{ partition: nextOffset.partition, offset: nextOffset.offset }] }]
894
+ topics: [
895
+ {
896
+ topic: nextOffset.topic,
897
+ partitions: [
898
+ {
899
+ partition: nextOffset.partition,
900
+ offset: nextOffset.offset
901
+ }
902
+ ]
903
+ }
904
+ ]
878
905
  });
879
906
  await tx.commit();
880
907
  logger.warn(
881
908
  `Message routed to ${rtTopic} (EOS, level ${nextLevel}/${currentMaxRetries})`
882
909
  );
910
+ onRetry?.(envelope, nextLevel, currentMaxRetries);
883
911
  } catch (txErr) {
884
912
  try {
885
913
  await tx.abort();
@@ -907,10 +935,21 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
907
935
  await tx.send({ topic: dTopic, messages: dMsgs });
908
936
  await tx.sendOffsets({
909
937
  consumer,
910
- topics: [{ topic: nextOffset.topic, partitions: [{ partition: nextOffset.partition, offset: nextOffset.offset }] }]
938
+ topics: [
939
+ {
940
+ topic: nextOffset.topic,
941
+ partitions: [
942
+ {
943
+ partition: nextOffset.partition,
944
+ offset: nextOffset.offset
945
+ }
946
+ ]
947
+ }
948
+ ]
911
949
  });
912
950
  await tx.commit();
913
951
  logger.warn(`Message sent to DLQ: ${dTopic} (EOS)`);
952
+ onDlq?.(envelope, "handler-error");
914
953
  } catch (txErr) {
915
954
  try {
916
955
  await tx.abort();
@@ -934,7 +973,12 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
934
973
  }
935
974
  });
936
975
  runningConsumers.set(levelGroupId, "eachMessage");
937
- await waitForPartitionAssignment(consumer, levelTopics, logger, assignmentTimeoutMs);
976
+ await waitForPartitionAssignment(
977
+ consumer,
978
+ levelTopics,
979
+ logger,
980
+ assignmentTimeoutMs
981
+ );
938
982
  logger.log(
939
983
  `Retry level ${level}/${retry.maxRetries} consumer started for: ${originalTopics.join(", ")} (group: ${levelGroupId})`
940
984
  );
@@ -964,6 +1008,7 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
964
1008
 
965
1009
  // src/client/kafka.client/index.ts
966
1010
  var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = KafkaJS;
1011
+ var _activeTransactionalIds = /* @__PURE__ */ new Set();
967
1012
  var KafkaClient = class {
968
1013
  kafka;
969
1014
  producer;
@@ -989,6 +1034,15 @@ var KafkaClient = class {
989
1034
  instrumentation;
990
1035
  onMessageLost;
991
1036
  onRebalance;
1037
+ /** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
1038
+ txId;
1039
+ /** Internal event counters exposed via `getMetrics()`. */
1040
+ _metrics = {
1041
+ processedCount: 0,
1042
+ retryCount: 0,
1043
+ dlqCount: 0,
1044
+ dedupCount: 0
1045
+ };
992
1046
  /** Monotonically increasing Lamport clock stamped on every outgoing message. */
993
1047
  _lamportClock = 0;
994
1048
  /** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
@@ -1012,6 +1066,7 @@ var KafkaClient = class {
1012
1066
  this.instrumentation = options?.instrumentation ?? [];
1013
1067
  this.onMessageLost = options?.onMessageLost;
1014
1068
  this.onRebalance = options?.onRebalance;
1069
+ this.txId = options?.transactionalId ?? `${clientId}-tx`;
1015
1070
  this.kafka = new KafkaClass({
1016
1071
  kafkaJS: {
1017
1072
  clientId: this.clientId,
@@ -1048,16 +1103,22 @@ var KafkaClient = class {
1048
1103
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
1049
1104
  async transaction(fn) {
1050
1105
  if (!this.txProducerInitPromise) {
1106
+ if (_activeTransactionalIds.has(this.txId)) {
1107
+ this.logger.warn(
1108
+ `transactionalId "${this.txId}" is already in use by another KafkaClient in this process. Kafka will fence one of the producers. Set a unique \`transactionalId\` (or distinct \`clientId\`) per instance.`
1109
+ );
1110
+ }
1051
1111
  const initPromise = (async () => {
1052
1112
  const p = this.kafka.producer({
1053
1113
  kafkaJS: {
1054
1114
  acks: -1,
1055
1115
  idempotent: true,
1056
- transactionalId: `${this.clientId}-tx`,
1116
+ transactionalId: this.txId,
1057
1117
  maxInFlightRequests: 1
1058
1118
  }
1059
1119
  });
1060
1120
  await p.connect();
1121
+ _activeTransactionalIds.add(this.txId);
1061
1122
  return p;
1062
1123
  })();
1063
1124
  this.txProducerInitPromise = initPromise.catch((err) => {
@@ -1128,7 +1189,10 @@ var KafkaClient = class {
1128
1189
  const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
1129
1190
  const deps = this.messageDeps;
1130
1191
  const timeoutMs = options.handlerTimeoutMs;
1131
- const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1192
+ const deduplication = this.resolveDeduplicationContext(
1193
+ gid,
1194
+ options.deduplication
1195
+ );
1132
1196
  await consumer.run({
1133
1197
  eachMessage: (payload) => this.trackInFlight(
1134
1198
  () => handleEachMessage(
@@ -1182,7 +1246,10 @@ var KafkaClient = class {
1182
1246
  const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
1183
1247
  const deps = this.messageDeps;
1184
1248
  const timeoutMs = options.handlerTimeoutMs;
1185
- const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1249
+ const deduplication = this.resolveDeduplicationContext(
1250
+ gid,
1251
+ options.deduplication
1252
+ );
1186
1253
  await consumer.run({
1187
1254
  eachBatch: (payload) => this.trackInFlight(
1188
1255
  () => handleEachBatch(
@@ -1209,7 +1276,7 @@ var KafkaClient = class {
1209
1276
  }
1210
1277
  const handleMessageForRetry = (env) => handleBatch([env], {
1211
1278
  partition: env.partition,
1212
- highWatermark: env.offset,
1279
+ highWatermark: null,
1213
1280
  heartbeat: async () => {
1214
1281
  },
1215
1282
  resolveOffset: () => {
@@ -1277,6 +1344,7 @@ var KafkaClient = class {
1277
1344
  toError(e).message
1278
1345
  )
1279
1346
  );
1347
+ _activeTransactionalIds.delete(txId);
1280
1348
  this.retryTxProducers.delete(txId);
1281
1349
  }
1282
1350
  }
@@ -1352,15 +1420,28 @@ var KafkaClient = class {
1352
1420
  getClientId() {
1353
1421
  return this.clientId;
1354
1422
  }
1423
+ getMetrics() {
1424
+ return { ...this._metrics };
1425
+ }
1426
+ resetMetrics() {
1427
+ this._metrics.processedCount = 0;
1428
+ this._metrics.retryCount = 0;
1429
+ this._metrics.dlqCount = 0;
1430
+ this._metrics.dedupCount = 0;
1431
+ }
1355
1432
  /** Gracefully disconnect producer, all consumers, and admin. */
1356
1433
  async disconnect(drainTimeoutMs = 3e4) {
1357
1434
  await this.waitForDrain(drainTimeoutMs);
1358
1435
  const tasks = [this.producer.disconnect()];
1359
1436
  if (this.txProducer) {
1360
1437
  tasks.push(this.txProducer.disconnect());
1438
+ _activeTransactionalIds.delete(this.txId);
1361
1439
  this.txProducer = void 0;
1362
1440
  this.txProducerInitPromise = void 0;
1363
1441
  }
1442
+ for (const txId of this.retryTxProducers.keys()) {
1443
+ _activeTransactionalIds.delete(txId);
1444
+ }
1364
1445
  for (const p of this.retryTxProducers.values()) {
1365
1446
  tasks.push(p.disconnect());
1366
1447
  }
@@ -1456,6 +1537,30 @@ var KafkaClient = class {
1456
1537
  }
1457
1538
  }
1458
1539
  }
1540
+ notifyRetry(envelope, attempt, maxRetries) {
1541
+ this._metrics.retryCount++;
1542
+ for (const inst of this.instrumentation) {
1543
+ inst.onRetry?.(envelope, attempt, maxRetries);
1544
+ }
1545
+ }
1546
+ notifyDlq(envelope, reason) {
1547
+ this._metrics.dlqCount++;
1548
+ for (const inst of this.instrumentation) {
1549
+ inst.onDlq?.(envelope, reason);
1550
+ }
1551
+ }
1552
+ notifyDuplicate(envelope, strategy) {
1553
+ this._metrics.dedupCount++;
1554
+ for (const inst of this.instrumentation) {
1555
+ inst.onDuplicate?.(envelope, strategy);
1556
+ }
1557
+ }
1558
+ notifyMessage(envelope) {
1559
+ this._metrics.processedCount++;
1560
+ for (const inst of this.instrumentation) {
1561
+ inst.onMessage?.(envelope);
1562
+ }
1563
+ }
1459
1564
  /**
1460
1565
  * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
1461
1566
  * The handler itself is not cancelled — the warning is diagnostic only.
@@ -1545,6 +1650,11 @@ var KafkaClient = class {
1545
1650
  * so Kafka can fence stale producers on restart without affecting other levels.
1546
1651
  */
1547
1652
  async createRetryTxProducer(transactionalId) {
1653
+ if (_activeTransactionalIds.has(transactionalId)) {
1654
+ this.logger.warn(
1655
+ `transactionalId "${transactionalId}" is already in use by another KafkaClient in this process. Kafka will fence one of the producers. Set a unique \`transactionalId\` (or distinct \`clientId\`) per instance.`
1656
+ );
1657
+ }
1548
1658
  const p = this.kafka.producer({
1549
1659
  kafkaJS: {
1550
1660
  acks: -1,
@@ -1554,6 +1664,7 @@ var KafkaClient = class {
1554
1664
  }
1555
1665
  });
1556
1666
  await p.connect();
1667
+ _activeTransactionalIds.add(transactionalId);
1557
1668
  this.retryTxProducers.set(transactionalId, p);
1558
1669
  return p;
1559
1670
  }
@@ -1674,7 +1785,11 @@ var KafkaClient = class {
1674
1785
  logger: this.logger,
1675
1786
  producer: this.producer,
1676
1787
  instrumentation: this.instrumentation,
1677
- onMessageLost: this.onMessageLost
1788
+ onMessageLost: this.onMessageLost,
1789
+ onRetry: this.notifyRetry.bind(this),
1790
+ onDlq: this.notifyDlq.bind(this),
1791
+ onDuplicate: this.notifyDuplicate.bind(this),
1792
+ onMessage: this.notifyMessage.bind(this)
1678
1793
  };
1679
1794
  }
1680
1795
  get retryTopicDeps() {
@@ -1683,6 +1798,9 @@ var KafkaClient = class {
1683
1798
  producer: this.producer,
1684
1799
  instrumentation: this.instrumentation,
1685
1800
  onMessageLost: this.onMessageLost,
1801
+ onRetry: this.notifyRetry.bind(this),
1802
+ onDlq: this.notifyDlq.bind(this),
1803
+ onMessage: this.notifyMessage.bind(this),
1686
1804
  ensureTopic: (t) => this.ensureTopic(t),
1687
1805
  getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
1688
1806
  runningConsumers: this.runningConsumers,
@@ -1725,4 +1843,4 @@ export {
1725
1843
  KafkaClient,
1726
1844
  topic
1727
1845
  };
1728
- //# sourceMappingURL=chunk-KCUKXR6B.mjs.map
1846
+ //# sourceMappingURL=chunk-ISYOEX4W.mjs.map