@taphealth/kafka 1.6.52 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,22 @@ await producer.send({
46
46
  });
47
47
  ```
48
48
 
49
+ `send()` now returns `boolean`:
50
+ - `true` when publish succeeds
51
+ - `false` when publish fails (defensive, non-throwing)
52
+
53
+ Producer instances are long-lived and reused across calls. On process shutdown, call:
54
+
55
+ ```typescript
56
+ await producer.disconnect();
57
+ ```
58
+
59
+ Producer instrumentation:
60
+ - Starts span: `kafka.produce <topic>`
61
+ - Adds attributes: `messaging.system`, `messaging.destination.name`, `messaging.operation=publish`
62
+ - Injects W3C trace headers into message headers
63
+ - Records metric `kafka.produce.error` on publish failures
64
+
49
65
  ### Consuming Messages
50
66
 
51
67
  Extend KafkaConsumer to implement message handling logic:
@@ -66,3 +82,45 @@ export class YourEventConsumer extends KafkaConsumer<YourEventInterface> {
66
82
  const consumer = new YourEventConsumer(kafkaClient.client);
67
83
  await consumer.consume();
68
84
  ```
85
+
86
+ Consumer error behavior:
87
+ - Malformed/poison payloads are skipped (consumer moves forward)
88
+ - `onMessage` errors are rethrown (Kafka retries; handlers must be idempotent)
89
+
90
+ Advanced consume options:
91
+
92
+ ```typescript
93
+ await consumer.consume({
94
+ autoCommit: false,
95
+ retry: {
96
+ maxRetries: 3,
97
+ backoffMs: (attempt) => attempt * 250,
98
+ },
99
+ onDeadLetter: async (data, message, err) => {
100
+ // optional custom dead-letter sink
101
+ },
102
+ });
103
+ ```
104
+
105
+ Handler context:
106
+ - `onMessage` receives optional third arg `{ heartbeat, attempt }`
107
+ - Use `heartbeat()` for long-running handlers
108
+ - `attempt` starts at `1` and increments per retry
109
+
110
+ Consumer instrumentation:
111
+ - Starts span: `kafka.consume <topic>`
112
+ - Extracts W3C trace headers from message headers
113
+ - Adds attributes: `messaging.system`, `messaging.destination.name`, `messaging.operation=receive`, partition, offset
114
+ - Records `kafka.consume.poison` for parse failures and `kafka.consume.handler_error` for handler failures
115
+
116
+ Reserved env flag:
117
+
118
+ ```bash
119
+ KAFKA_CONSUMER_DLQ_ENABLED=false
120
+ ```
121
+
122
+ ## Migration notes
123
+
124
+ - `onMessage` can now be async safely; rejections are awaited and rethrown to KafkaJS.
125
+ - To opt into crash-safe manual commits, pass `autoCommit: false` to `consume()`.
126
+ - If you rely on retries beyond KafkaJS defaults, pass `retry` + `onDeadLetter` in `consume()` options.
@@ -0,0 +1,284 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const consumer_1 = require("../consumer");
4
+ const topics_1 = require("../topics");
5
+ const assert = (condition, message) => {
6
+ if (!condition) {
7
+ throw new Error(message);
8
+ }
9
+ };
10
+ class ConsumerStub {
11
+ constructor() {
12
+ this.connectCalls = 0;
13
+ this.subscribeCalls = 0;
14
+ this.disconnectCalls = 0;
15
+ this.commitCalls = [];
16
+ this.commitShouldThrow = false;
17
+ }
18
+ async connect() {
19
+ this.connectCalls += 1;
20
+ }
21
+ async subscribe() {
22
+ this.subscribeCalls += 1;
23
+ }
24
+ async run(config) {
25
+ this.eachMessage = config.eachMessage;
26
+ this.runOptions = {
27
+ autoCommit: config.autoCommit,
28
+ };
29
+ }
30
+ async commitOffsets(offsets) {
31
+ if (this.commitShouldThrow) {
32
+ throw new Error("commit failed (rebalance)");
33
+ }
34
+ this.commitCalls.push(...offsets);
35
+ }
36
+ async disconnect() {
37
+ this.disconnectCalls += 1;
38
+ }
39
+ }
40
+ class KafkaStub {
41
+ constructor() {
42
+ this.consumerInstance = new ConsumerStub();
43
+ }
44
+ consumer() {
45
+ return this.consumerInstance;
46
+ }
47
+ }
48
+ class TestConsumer extends consumer_1.KafkaConsumer {
49
+ constructor() {
50
+ super(...arguments);
51
+ this.topic = topics_1.Topics.SubscriptionCreated;
52
+ this.groupId = "test-group";
53
+ this.shouldThrow = false;
54
+ this.shouldRejectAsync = false;
55
+ this.remainingFailures = 0;
56
+ this.heartbeatCalls = 0;
57
+ this.attempts = [];
58
+ this.received = [];
59
+ }
60
+ async onMessage(data, _msg, context) {
61
+ if (context) {
62
+ this.attempts.push(context.attempt);
63
+ await context.heartbeat();
64
+ this.heartbeatCalls += 1;
65
+ }
66
+ if (this.remainingFailures > 0) {
67
+ this.remainingFailures -= 1;
68
+ throw new Error("handler temporary failure");
69
+ }
70
+ if (this.shouldThrow) {
71
+ throw new Error("handler failed");
72
+ }
73
+ if (this.shouldRejectAsync) {
74
+ return Promise.reject(new Error("handler failed async"));
75
+ }
76
+ this.received.push(data);
77
+ }
78
+ }
79
+ const run = async () => {
80
+ var _a;
81
+ const kafka = new KafkaStub();
82
+ const consumer = new TestConsumer(kafka);
83
+ await consumer.consume({ autoCommit: false });
84
+ assert(kafka.consumerInstance.connectCalls === 1, "consumer should connect once");
85
+ assert(kafka.consumerInstance.subscribeCalls === 1, "consumer should subscribe once");
86
+ assert(!!kafka.consumerInstance.eachMessage, "eachMessage handler should be registered");
87
+ assert(((_a = kafka.consumerInstance.runOptions) === null || _a === void 0 ? void 0 : _a.autoCommit) === false, "consume options should forward autoCommit value to kafkajs");
88
+ await kafka.consumerInstance.eachMessage({
89
+ message: {
90
+ value: { toString: () => '{"id":"ok"}' },
91
+ offset: "1",
92
+ },
93
+ partition: 0,
94
+ heartbeat: async () => undefined,
95
+ });
96
+ assert(consumer.received.length === 1, "valid message should reach handler");
97
+ assert(kafka.consumerInstance.commitCalls.length === 1 &&
98
+ kafka.consumerInstance.commitCalls[0].offset === "2" &&
99
+ kafka.consumerInstance.commitCalls[0].partition === 0, "autoCommit:false success path should manually commit next offset (message.offset + 1)");
100
+ await kafka.consumerInstance.eachMessage({
101
+ message: {
102
+ value: { toString: () => "not-json" },
103
+ offset: "2",
104
+ },
105
+ partition: 0,
106
+ heartbeat: async () => undefined,
107
+ });
108
+ assert(consumer.received.length === 1, "poison message should be skipped");
109
+ assert(kafka.consumerInstance.commitCalls.length === 2 &&
110
+ kafka.consumerInstance.commitCalls[1].offset === "3", "autoCommit:false poison path should manually commit to advance past skipped message");
111
+ consumer.shouldThrow = true;
112
+ const commitsBeforeThrow = kafka.consumerInstance.commitCalls.length;
113
+ let handlerThrew = false;
114
+ try {
115
+ await kafka.consumerInstance.eachMessage({
116
+ message: {
117
+ value: { toString: () => '{"id":"boom"}' },
118
+ offset: "3",
119
+ },
120
+ partition: 0,
121
+ heartbeat: async () => undefined,
122
+ });
123
+ }
124
+ catch (_err) {
125
+ handlerThrew = true;
126
+ }
127
+ assert(handlerThrew, "handler errors should be rethrown");
128
+ assert(kafka.consumerInstance.commitCalls.length === commitsBeforeThrow, "autoCommit:false handler-error path with no DLQ should NOT commit (kafkajs will redeliver)");
129
+ consumer.shouldThrow = false;
130
+ consumer.shouldRejectAsync = true;
131
+ let asyncHandlerThrew = false;
132
+ try {
133
+ await kafka.consumerInstance.eachMessage({
134
+ message: {
135
+ value: { toString: () => '{"id":"boom-async"}' },
136
+ offset: "4",
137
+ },
138
+ partition: 0,
139
+ heartbeat: async () => undefined,
140
+ });
141
+ }
142
+ catch (_err) {
143
+ asyncHandlerThrew = true;
144
+ }
145
+ assert(asyncHandlerThrew, "async handler rejections should be rethrown");
146
+ assert(kafka.consumerInstance.commitCalls.length === commitsBeforeThrow, "autoCommit:false async-rejection path with no DLQ should NOT commit");
147
+ consumer.shouldRejectAsync = false;
148
+ consumer.remainingFailures = 2;
149
+ const deadLetterCalls = [];
150
+ await consumer.consume({
151
+ retry: {
152
+ maxRetries: 3,
153
+ backoffMs: () => 0,
154
+ },
155
+ onDeadLetter: async (data) => {
156
+ deadLetterCalls.push(data);
157
+ },
158
+ });
159
+ await kafka.consumerInstance.eachMessage({
160
+ message: {
161
+ value: { toString: () => '{"id":"retry-ok"}' },
162
+ offset: "5",
163
+ },
164
+ partition: 0,
165
+ heartbeat: async () => undefined,
166
+ });
167
+ assert(consumer.received.some((item) => item.id === "retry-ok"), "consumer should succeed after configured retries");
168
+ assert(consumer.attempts.includes(1) && consumer.attempts.includes(3), "consumer should pass attempt count to handler context");
169
+ assert(consumer.heartbeatCalls > 0, "consumer should pass heartbeat to handler context");
170
+ consumer.remainingFailures = 2;
171
+ await consumer.consume({
172
+ retry: {
173
+ maxRetries: 1,
174
+ backoffMs: () => 0,
175
+ },
176
+ onDeadLetter: async (data) => {
177
+ deadLetterCalls.push(data);
178
+ },
179
+ });
180
+ await kafka.consumerInstance.eachMessage({
181
+ message: {
182
+ value: { toString: () => '{"id":"dead-letter"}' },
183
+ offset: "6",
184
+ },
185
+ partition: 0,
186
+ heartbeat: async () => undefined,
187
+ });
188
+ assert(deadLetterCalls.some((item) => item.id === "dead-letter"), "onDeadLetter should be called after retry exhaustion");
189
+ consumer.remainingFailures = 2;
190
+ let deadLetterHookThrowSurfaces = false;
191
+ try {
192
+ await consumer.consume({
193
+ retry: {
194
+ maxRetries: 1,
195
+ backoffMs: () => 0,
196
+ },
197
+ onDeadLetter: async () => {
198
+ throw new Error("dead-letter hook failed");
199
+ },
200
+ });
201
+ await kafka.consumerInstance.eachMessage({
202
+ message: {
203
+ value: { toString: () => '{"id":"dead-letter-hook-throws"}' },
204
+ offset: "7",
205
+ },
206
+ partition: 0,
207
+ heartbeat: async () => undefined,
208
+ });
209
+ }
210
+ catch (_err) {
211
+ deadLetterHookThrowSurfaces = true;
212
+ }
213
+ assert(deadLetterHookThrowSurfaces, "consumer should rethrow when dead-letter hook itself throws");
214
+ // ─── autoCommit:false interaction with retry + DLQ + commit failures ──
215
+ const manualCommitConsumer = new TestConsumer(kafka);
216
+ // Reset commit log so subsequent assertions are independent.
217
+ kafka.consumerInstance.commitCalls = [];
218
+ manualCommitConsumer.remainingFailures = 2;
219
+ await manualCommitConsumer.consume({
220
+ autoCommit: false,
221
+ retry: { maxRetries: 3, backoffMs: () => 0 },
222
+ onDeadLetter: async () => undefined,
223
+ });
224
+ await kafka.consumerInstance.eachMessage({
225
+ message: { value: { toString: () => '{"id":"retry-then-commit"}' }, offset: "10" },
226
+ partition: 0,
227
+ heartbeat: async () => undefined,
228
+ });
229
+ assert(kafka.consumerInstance.commitCalls.length === 1 &&
230
+ kafka.consumerInstance.commitCalls[0].offset === "11", "autoCommit:false should commit ONCE after retry-then-success (one commit per message, not per attempt)");
231
+ // autoCommit:false + retry-exhaustion + DLQ-handled → must commit so the
232
+ // failed message does not get redelivered forever.
233
+ kafka.consumerInstance.commitCalls = [];
234
+ manualCommitConsumer.remainingFailures = 5;
235
+ let dlqHandled = false;
236
+ await manualCommitConsumer.consume({
237
+ autoCommit: false,
238
+ retry: { maxRetries: 1, backoffMs: () => 0 },
239
+ onDeadLetter: async () => {
240
+ dlqHandled = true;
241
+ },
242
+ });
243
+ await kafka.consumerInstance.eachMessage({
244
+ message: { value: { toString: () => '{"id":"dlq-then-commit"}' }, offset: "20" },
245
+ partition: 0,
246
+ heartbeat: async () => undefined,
247
+ });
248
+ assert(dlqHandled, "DLQ hook should fire on retry exhaustion");
249
+ assert(kafka.consumerInstance.commitCalls.length === 1 &&
250
+ kafka.consumerInstance.commitCalls[0].offset === "21", "autoCommit:false should commit after a DLQ-handled message so it is not redelivered");
251
+ // commitOffsets failure (rebalance, broker down) MUST be swallowed on the
252
+ // happy path — at-least-once is preserved by kafkajs replay on the next
253
+ // session, but propagating would force redelivery + perma-failure loop.
254
+ kafka.consumerInstance.commitCalls = [];
255
+ kafka.consumerInstance.commitShouldThrow = true;
256
+ manualCommitConsumer.remainingFailures = 0; // ensure handler succeeds cleanly
257
+ let commitFailureSurfaced = false;
258
+ try {
259
+ await kafka.consumerInstance.eachMessage({
260
+ message: { value: { toString: () => '{"id":"commit-fails-but-handler-ok"}' }, offset: "30" },
261
+ partition: 0,
262
+ heartbeat: async () => undefined,
263
+ });
264
+ }
265
+ catch (_err) {
266
+ commitFailureSurfaced = true;
267
+ }
268
+ assert(!commitFailureSurfaced, "commitOffsets failure must NOT propagate out of eachMessage (would cause kafkajs to redeliver and never commit)");
269
+ kafka.consumerInstance.commitShouldThrow = false;
270
+ // autoCommit:true (or undefined) → kafkajs handles commits, package must NOT
271
+ // call commitOffsets manually (would double-commit).
272
+ kafka.consumerInstance.commitCalls = [];
273
+ const autoCommitConsumer = new TestConsumer(kafka);
274
+ await autoCommitConsumer.consume({ autoCommit: true });
275
+ await kafka.consumerInstance.eachMessage({
276
+ message: { value: { toString: () => '{"id":"auto-commit-no-manual"}' }, offset: "40" },
277
+ partition: 0,
278
+ heartbeat: async () => undefined,
279
+ });
280
+ assert(kafka.consumerInstance.commitCalls.length === 0, "autoCommit:true must NOT trigger manual commitOffsets (kafkajs owns commit cadence)");
281
+ await consumer.disconnect();
282
+ assert(kafka.consumerInstance.disconnectCalls === 1, "disconnect should use the running consumer instance");
283
+ };
284
+ run();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const api_1 = require("@opentelemetry/api");
4
+ const producer_1 = require("../producer");
5
+ const topics_1 = require("../topics");
6
+ const assert = (condition, message) => {
7
+ if (!condition) {
8
+ throw new Error(message);
9
+ }
10
+ };
11
+ class ProducerStub {
12
+ constructor() {
13
+ this.connectCalls = 0;
14
+ this.disconnectCalls = 0;
15
+ this.sendCalls = 0;
16
+ this.shouldFail = false;
17
+ this.payloads = [];
18
+ }
19
+ async connect() {
20
+ this.connectCalls += 1;
21
+ }
22
+ async disconnect() {
23
+ this.disconnectCalls += 1;
24
+ }
25
+ async send(payload) {
26
+ this.sendCalls += 1;
27
+ this.payloads.push(payload);
28
+ if (this.shouldFail) {
29
+ throw new Error("send failed");
30
+ }
31
+ }
32
+ }
33
+ class KafkaStub {
34
+ constructor() {
35
+ this.producerFactoryCalls = 0;
36
+ this.producerInstance = new ProducerStub();
37
+ }
38
+ producer() {
39
+ this.producerFactoryCalls += 1;
40
+ return this.producerInstance;
41
+ }
42
+ }
43
+ class TestProducer extends producer_1.KafkaProducer {
44
+ constructor() {
45
+ super(...arguments);
46
+ this.topic = topics_1.Topics.SubscriptionCreated;
47
+ }
48
+ }
49
+ const run = async () => {
50
+ const kafka = new KafkaStub();
51
+ const loggerErrors = [];
52
+ const logger = {
53
+ info: () => undefined,
54
+ error: (_message, meta) => {
55
+ loggerErrors.push(meta);
56
+ },
57
+ };
58
+ const producer = new TestProducer(kafka, logger);
59
+ const activeContext = api_1.propagation.setBaggage(api_1.context.active(), api_1.propagation.createBaggage({
60
+ "user.id": { value: "abc" },
61
+ }));
62
+ const first = await api_1.context.with(activeContext, async () => {
63
+ return producer.send({ id: "1" });
64
+ });
65
+ const second = await producer.send({ id: "2" });
66
+ assert(first === true, "send should return true on first success");
67
+ assert(second === true, "send should return true on second success");
68
+ assert(kafka.producerFactoryCalls === 1, "producer should be created once and reused");
69
+ assert(kafka.producerInstance.connectCalls === 1, "producer connect should happen once");
70
+ assert(kafka.producerInstance.sendCalls === 2, "producer send should happen for each call");
71
+ const firstPayload = kafka.producerInstance.payloads[0];
72
+ assert(!!firstPayload.messages[0].headers, "producer should inject trace headers on outgoing messages");
73
+ kafka.producerInstance.shouldFail = true;
74
+ const failed = await producer.send({ id: "3" });
75
+ assert(failed === false, "send should return false on producer send error");
76
+ assert(loggerErrors.length === 1, "producer error should be logged once");
77
+ await producer.disconnect();
78
+ assert(kafka.producerInstance.disconnectCalls === 1, "disconnect should disconnect cached producer");
79
+ };
80
+ run();
@@ -0,0 +1 @@
1
+ export {};
@@ -1,16 +1,53 @@
1
1
  import { Kafka, Message } from "kafkajs";
2
+ import { Logger } from "./logger";
2
3
  interface Event {
3
4
  topic: string;
4
5
  data: any;
5
6
  }
7
+ export interface ConsumeRetryOptions {
8
+ maxRetries: number;
9
+ backoffMs: (attempt: number) => number;
10
+ }
11
+ export interface ConsumeHandlerContext {
12
+ heartbeat: () => Promise<void>;
13
+ attempt: number;
14
+ }
15
+ export interface ConsumeOptions<TData> {
16
+ autoCommit?: boolean;
17
+ retry?: ConsumeRetryOptions;
18
+ onDeadLetter?: (data: TData, message: Message, err: Error) => Promise<void> | void;
19
+ }
6
20
  export declare abstract class KafkaConsumer<T extends Event> {
7
21
  abstract topic: T["topic"];
8
22
  abstract groupId: string;
9
- abstract onMessage(data: T["data"], msg: Message): void;
23
+ abstract onMessage(data: T["data"], msg: Message, context?: ConsumeHandlerContext): void | Promise<void>;
10
24
  protected client: Kafka;
11
- constructor(client: Kafka);
12
- consume(): Promise<void>;
13
- parseMessage(msg: Message): any;
25
+ private consumerInstance?;
26
+ private logger;
27
+ constructor(client: Kafka, logger?: Logger);
28
+ private getConsumer;
29
+ /**
30
+ * Error handling behavior:
31
+ * - Poison parse errors are skipped so offsets can move forward.
32
+ * - Handler errors are rethrown so kafkajs retries message processing.
33
+ */
34
+ private executeWithRetry;
35
+ /**
36
+ * Commit the offset for a single processed message. Used when callers opt
37
+ * into manual commits via `autoCommit: false`. Failures are logged and
38
+ * swallowed: at-least-once delivery is preserved because kafkajs will
39
+ * resume from the last successfully committed offset on the next session.
40
+ */
41
+ private commitMessageOffset;
42
+ /**
43
+ * Error handling behavior:
44
+ * - Poison parse errors are skipped so offsets can move forward.
45
+ * - Handler errors are rethrown so kafkajs retries message processing.
46
+ * - With `autoCommit: false`, offsets are manually committed after a
47
+ * message is fully handled (success, poison-skip, or DLQ-handled).
48
+ */
49
+ consume(options?: ConsumeOptions<T["data"]>): Promise<void>;
50
+ parseMessage(msg: Message): T["data"];
14
51
  disconnect(): Promise<void>;
15
52
  }
16
53
  export {};
package/dist/consumer.js CHANGED
@@ -1,21 +1,180 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KafkaConsumer = void 0;
4
+ const api_1 = require("@opentelemetry/api");
5
+ const logger_1 = require("./logger");
6
+ const telemetry_1 = require("./telemetry");
7
+ const toError = (err) => {
8
+ if (err instanceof Error) {
9
+ return err;
10
+ }
11
+ return new Error(String(err));
12
+ };
13
+ const sleep = async (ms) => new Promise((resolve) => {
14
+ setTimeout(resolve, ms);
15
+ });
4
16
  class KafkaConsumer {
5
- constructor(client) {
17
+ constructor(client, logger = logger_1.defaultLogger) {
6
18
  this.client = client;
19
+ this.logger = logger;
20
+ }
21
+ getConsumer() {
22
+ if (!this.consumerInstance) {
23
+ this.consumerInstance = this.client.consumer({
24
+ groupId: this.groupId,
25
+ });
26
+ }
27
+ return this.consumerInstance;
7
28
  }
8
- async consume() {
9
- const consumer = this.client.consumer({
10
- groupId: this.groupId,
29
+ /**
30
+ * Error handling behavior:
31
+ * - Poison parse errors are skipped so offsets can move forward.
32
+ * - Handler errors are rethrown so kafkajs retries message processing.
33
+ */
34
+ async executeWithRetry(data, message, heartbeat, options) {
35
+ var _a, _b, _c, _d;
36
+ const maxRetries = (_b = (_a = options === null || options === void 0 ? void 0 : options.retry) === null || _a === void 0 ? void 0 : _a.maxRetries) !== null && _b !== void 0 ? _b : 0;
37
+ const backoffMs = (_d = (_c = options === null || options === void 0 ? void 0 : options.retry) === null || _c === void 0 ? void 0 : _c.backoffMs) !== null && _d !== void 0 ? _d : (() => {
38
+ return 0;
11
39
  });
40
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt += 1) {
41
+ try {
42
+ await this.onMessage(data, message, { heartbeat, attempt });
43
+ return;
44
+ }
45
+ catch (err) {
46
+ const error = toError(err);
47
+ (0, telemetry_1.incrementConsumeHandlerError)(this.topic);
48
+ if (attempt <= maxRetries) {
49
+ await sleep(backoffMs(attempt));
50
+ continue;
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * Commit the offset for a single processed message. Used when callers opt
58
+ * into manual commits via `autoCommit: false`. Failures are logged and
59
+ * swallowed: at-least-once delivery is preserved because kafkajs will
60
+ * resume from the last successfully committed offset on the next session.
61
+ */
62
+ async commitMessageOffset(consumer, partition, offset) {
63
+ try {
64
+ const nextOffset = (BigInt(offset) + BigInt(1)).toString();
65
+ await consumer.commitOffsets([
66
+ {
67
+ topic: this.topic,
68
+ partition,
69
+ offset: nextOffset,
70
+ },
71
+ ]);
72
+ }
73
+ catch (err) {
74
+ this.logger.error("Failed to commit kafka offset", {
75
+ topic: this.topic,
76
+ partition,
77
+ offset,
78
+ error: toError(err),
79
+ });
80
+ }
81
+ }
82
+ /**
83
+ * Error handling behavior:
84
+ * - Poison parse errors are skipped so offsets can move forward.
85
+ * - Handler errors are rethrown so kafkajs retries message processing.
86
+ * - With `autoCommit: false`, offsets are manually committed after a
87
+ * message is fully handled (success, poison-skip, or DLQ-handled).
88
+ */
89
+ async consume(options) {
90
+ const consumer = this.getConsumer();
91
+ const manualCommit = (options === null || options === void 0 ? void 0 : options.autoCommit) === false;
12
92
  await consumer.connect();
13
93
  await consumer.subscribe({ topics: [this.topic], fromBeginning: true });
94
+ const dlqEnabled = process.env.KAFKA_CONSUMER_DLQ_ENABLED === "true";
95
+ if (dlqEnabled) {
96
+ this.logger.info("KAFKA_CONSUMER_DLQ_ENABLED is set (reserved, no-op)", {
97
+ topic: this.topic,
98
+ });
99
+ }
14
100
  await consumer.run({
15
- eachMessage: async ({ message, partition }) => {
16
- console.log("Received message on topic", this.topic, " and partition", partition);
17
- const data = this.parseMessage(message);
18
- this.onMessage(data, message);
101
+ autoCommit: options === null || options === void 0 ? void 0 : options.autoCommit,
102
+ eachMessage: async ({ message, partition, heartbeat }) => {
103
+ const extractionContext = (0, telemetry_1.extractContextFromMessage)(message);
104
+ await telemetry_1.kafkaTracer.startActiveSpan(`kafka.consume ${this.topic}`, {
105
+ attributes: {
106
+ "messaging.system": "kafka",
107
+ "messaging.destination.name": this.topic,
108
+ "messaging.operation": "receive",
109
+ "messaging.kafka.partition": partition,
110
+ "messaging.kafka.offset": message.offset,
111
+ },
112
+ }, extractionContext, async (span) => {
113
+ this.logger.info("Received message on topic", {
114
+ topic: this.topic,
115
+ partition,
116
+ offset: message.offset,
117
+ });
118
+ let data;
119
+ try {
120
+ data = this.parseMessage(message);
121
+ }
122
+ catch (err) {
123
+ const error = toError(err);
124
+ (0, telemetry_1.incrementConsumePoison)(this.topic);
125
+ span.recordException(error);
126
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
127
+ this.logger.error("Skipping poison kafka message", {
128
+ topic: this.topic,
129
+ partition,
130
+ offset: message.offset,
131
+ error,
132
+ });
133
+ if (manualCommit) {
134
+ await this.commitMessageOffset(consumer, partition, message.offset);
135
+ }
136
+ span.end();
137
+ return;
138
+ }
139
+ try {
140
+ await this.executeWithRetry(data, message, heartbeat, options);
141
+ }
142
+ catch (err) {
143
+ const error = toError(err);
144
+ span.recordException(error);
145
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
146
+ this.logger.error("Kafka consumer handler failed", {
147
+ topic: this.topic,
148
+ partition,
149
+ offset: message.offset,
150
+ error,
151
+ });
152
+ if (options === null || options === void 0 ? void 0 : options.onDeadLetter) {
153
+ try {
154
+ await options.onDeadLetter(data, message, error);
155
+ if (manualCommit) {
156
+ await this.commitMessageOffset(consumer, partition, message.offset);
157
+ }
158
+ span.end();
159
+ return;
160
+ }
161
+ catch (deadLetterError) {
162
+ this.logger.error("Kafka consumer dead-letter hook failed", {
163
+ topic: this.topic,
164
+ partition,
165
+ offset: message.offset,
166
+ error: toError(deadLetterError),
167
+ });
168
+ }
169
+ }
170
+ span.end();
171
+ throw error;
172
+ }
173
+ if (manualCommit) {
174
+ await this.commitMessageOffset(consumer, partition, message.offset);
175
+ }
176
+ span.end();
177
+ });
19
178
  },
20
179
  });
21
180
  }
@@ -25,7 +184,11 @@ class KafkaConsumer {
25
184
  return data;
26
185
  }
27
186
  async disconnect() {
28
- await this.client.consumer({ groupId: this.groupId }).disconnect();
187
+ if (!this.consumerInstance) {
188
+ return;
189
+ }
190
+ await this.consumerInstance.disconnect();
191
+ this.consumerInstance = undefined;
29
192
  }
30
193
  }
31
194
  exports.KafkaConsumer = KafkaConsumer;
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./topics";
2
2
  export * from "./kafka";
3
3
  export * from "./producer";
4
4
  export * from "./consumer";
5
+ export * from "./logger";
5
6
  export * from "./types/appointment";
6
7
  export * from "./types/payment";
7
8
  export * from "./types/subscription";
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ __exportStar(require("./topics"), exports);
18
18
  __exportStar(require("./kafka"), exports);
19
19
  __exportStar(require("./producer"), exports);
20
20
  __exportStar(require("./consumer"), exports);
21
+ __exportStar(require("./logger"), exports);
21
22
  __exportStar(require("./types/appointment"), exports);
22
23
  __exportStar(require("./types/payment"), exports);
23
24
  __exportStar(require("./types/subscription"), exports);
@@ -0,0 +1,5 @@
1
+ export interface Logger {
2
+ info(message: string, meta?: Record<string, unknown>): void;
3
+ error(message: string, meta?: Record<string, unknown>): void;
4
+ }
5
+ export declare const defaultLogger: Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.defaultLogger = void 0;
7
+ const middlewares_1 = require("@taphealth/middlewares");
8
+ const winston_1 = __importDefault(require("winston"));
9
+ const logger = winston_1.default.createLogger({
10
+ level: process.env.LOG_LEVEL || "info",
11
+ format: winston_1.default.format.combine(winston_1.default.format.timestamp(), winston_1.default.format.errors({ stack: true }), winston_1.default.format.json()),
12
+ transports: [
13
+ new winston_1.default.transports.Console(),
14
+ (0, middlewares_1.createOtelWinstonTransport)(),
15
+ ],
16
+ });
17
+ exports.defaultLogger = {
18
+ info(message, meta) {
19
+ logger.info(message, meta !== null && meta !== void 0 ? meta : {});
20
+ },
21
+ error(message, meta) {
22
+ logger.error(message, meta !== null && meta !== void 0 ? meta : {});
23
+ },
24
+ };
@@ -1,4 +1,5 @@
1
1
  import { Kafka } from "kafkajs";
2
+ import { Logger } from "./logger";
2
3
  import { Topics } from "./topics";
3
4
  interface Event {
4
5
  topic: Topics;
@@ -7,7 +8,12 @@ interface Event {
7
8
  export declare abstract class KafkaProducer<T extends Event> {
8
9
  abstract topic: T["topic"];
9
10
  protected client: Kafka;
10
- constructor(client: Kafka);
11
- send(data: T["data"]): Promise<void>;
11
+ private producerInstance?;
12
+ private isProducerConnected;
13
+ private logger;
14
+ constructor(client: Kafka, logger?: Logger);
15
+ private getProducer;
16
+ send(data: T["data"]): Promise<boolean>;
17
+ disconnect(): Promise<void>;
12
18
  }
13
19
  export {};
package/dist/producer.js CHANGED
@@ -1,27 +1,71 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KafkaProducer = void 0;
4
+ const api_1 = require("@opentelemetry/api");
5
+ const logger_1 = require("./logger");
6
+ const telemetry_1 = require("./telemetry");
4
7
  class KafkaProducer {
5
- constructor(client) {
8
+ constructor(client, logger = logger_1.defaultLogger) {
9
+ this.isProducerConnected = false;
6
10
  this.client = client;
11
+ this.logger = logger;
7
12
  }
8
- async send(data) {
9
- try {
10
- const producer = this.client.producer();
11
- await producer.connect();
12
- await producer.send({
13
- topic: this.topic,
14
- messages: [{
15
- partition: 0,
16
- value: JSON.stringify(data)
17
- }],
18
- });
19
- console.log("Data sent to Kafka topic", this.topic);
20
- await producer.disconnect();
13
+ getProducer() {
14
+ if (!this.producerInstance) {
15
+ this.producerInstance = this.client.producer();
21
16
  }
22
- catch (err) {
23
- console.log("Error in sending data to Kafka", err);
17
+ return this.producerInstance;
18
+ }
19
+ async send(data) {
20
+ return telemetry_1.kafkaTracer.startActiveSpan(`kafka.produce ${this.topic}`, {
21
+ attributes: {
22
+ "messaging.system": "kafka",
23
+ "messaging.destination.name": this.topic,
24
+ "messaging.operation": "publish",
25
+ },
26
+ }, async (span) => {
27
+ try {
28
+ const producer = this.getProducer();
29
+ if (!this.isProducerConnected) {
30
+ await producer.connect();
31
+ this.isProducerConnected = true;
32
+ }
33
+ await producer.send({
34
+ topic: this.topic,
35
+ messages: [
36
+ {
37
+ partition: 0,
38
+ value: JSON.stringify(data),
39
+ headers: (0, telemetry_1.injectTraceHeaders)(),
40
+ },
41
+ ],
42
+ });
43
+ this.logger.info("Data sent to Kafka topic", { topic: this.topic });
44
+ return true;
45
+ }
46
+ catch (err) {
47
+ span.recordException(err);
48
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
49
+ (0, telemetry_1.incrementProduceError)(this.topic);
50
+ this.logger.error("Error in sending data to Kafka", {
51
+ topic: this.topic,
52
+ error: err,
53
+ });
54
+ this.isProducerConnected = false;
55
+ return false;
56
+ }
57
+ finally {
58
+ span.end();
59
+ }
60
+ });
61
+ }
62
+ async disconnect() {
63
+ if (!this.producerInstance) {
64
+ return;
24
65
  }
66
+ await this.producerInstance.disconnect();
67
+ this.isProducerConnected = false;
68
+ this.producerInstance = undefined;
25
69
  }
26
70
  }
27
71
  exports.KafkaProducer = KafkaProducer;
@@ -0,0 +1,9 @@
1
+ import { Message } from "kafkajs";
2
+ export declare const kafkaTracer: import("@opentelemetry/api").Tracer;
3
+ export declare const injectTraceHeaders: (initialHeaders?: Record<string, string>) => {
4
+ [x: string]: string;
5
+ };
6
+ export declare const extractContextFromMessage: (message: Message) => import("@opentelemetry/api").Context;
7
+ export declare const incrementProduceError: (topic: string) => void;
8
+ export declare const incrementConsumePoison: (topic: string) => void;
9
+ export declare const incrementConsumeHandlerError: (topic: string) => void;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.incrementConsumeHandlerError = exports.incrementConsumePoison = exports.incrementProduceError = exports.extractContextFromMessage = exports.injectTraceHeaders = exports.kafkaTracer = void 0;
4
+ const api_1 = require("@opentelemetry/api");
5
+ exports.kafkaTracer = api_1.trace.getTracer("@taphealth/kafka");
6
+ const kafkaMeter = api_1.metrics.getMeter("@taphealth/kafka");
7
+ const produceErrorCounter = kafkaMeter.createCounter("kafka.produce.error", {
8
+ description: "Count of kafka produce failures",
9
+ });
10
+ const consumePoisonCounter = kafkaMeter.createCounter("kafka.consume.poison", {
11
+ description: "Count of kafka poison messages skipped during parsing",
12
+ });
13
+ const consumeHandlerErrorCounter = kafkaMeter.createCounter("kafka.consume.handler_error", {
14
+ description: "Count of kafka consumer handler failures",
15
+ });
16
+ const toHeaderValue = (value) => {
17
+ if (!value) {
18
+ return "";
19
+ }
20
+ if (Array.isArray(value)) {
21
+ const [first] = value;
22
+ return toHeaderValue(first);
23
+ }
24
+ if (typeof value === "string") {
25
+ return value;
26
+ }
27
+ return value.toString("utf8");
28
+ };
29
+ const injectTraceHeaders = (initialHeaders = {}) => {
30
+ const headers = Object.assign({}, initialHeaders);
31
+ api_1.propagation.inject(api_1.context.active(), headers);
32
+ return headers;
33
+ };
34
+ exports.injectTraceHeaders = injectTraceHeaders;
35
+ const extractContextFromMessage = (message) => {
36
+ var _a;
37
+ const carrier = Object.entries((_a = message.headers) !== null && _a !== void 0 ? _a : {}).reduce((acc, [key, value]) => {
38
+ const headerValue = toHeaderValue(value);
39
+ if (headerValue) {
40
+ acc[key] = headerValue;
41
+ }
42
+ return acc;
43
+ }, {});
44
+ return api_1.propagation.extract(api_1.context.active(), carrier);
45
+ };
46
+ exports.extractContextFromMessage = extractContextFromMessage;
47
+ const incrementProduceError = (topic) => {
48
+ produceErrorCounter.add(1, { topic });
49
+ };
50
+ exports.incrementProduceError = incrementProduceError;
51
+ const incrementConsumePoison = (topic) => {
52
+ consumePoisonCounter.add(1, { topic });
53
+ };
54
+ exports.incrementConsumePoison = incrementConsumePoison;
55
+ const incrementConsumeHandlerError = (topic) => {
56
+ consumeHandlerErrorCounter.add(1, { topic });
57
+ };
58
+ exports.incrementConsumeHandlerError = incrementConsumeHandlerError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taphealth/kafka",
3
- "version": "1.6.52",
3
+ "version": "2.0.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -16,13 +16,19 @@
16
16
  "typescript": "^5.4.5"
17
17
  },
18
18
  "dependencies": {
19
+ "@opentelemetry/api": "^1.9.0",
19
20
  "@taphealth/errors": "^1.1.8",
20
- "kafkajs": "^2.2.4"
21
+ "@taphealth/middlewares": "^1.2.13",
22
+ "kafkajs": "^2.2.4",
23
+ "winston": "^3.19.0"
21
24
  },
22
25
  "scripts": {
23
26
  "clean": "del-cli ./dist/*",
24
27
  "build": "pnpm clean && tsc",
25
- "test:contracts": "ts-node ./src/tests/weekly-command-contract.test.ts",
28
+ "test:producer": "ts-node ./src/__tests__/producer-behavior.test.ts",
29
+ "test:consumer": "ts-node ./src/__tests__/consumer-behavior.test.ts",
30
+ "test": "pnpm test:contracts && pnpm test:producer && pnpm test:consumer",
31
+ "test:contracts": "ts-node ./src/__tests__/weekly-command-contract.test.ts",
26
32
  "pub": "pnpm version patch --no-git-checks && pnpm build && pnpm publish --no-git-checks"
27
33
  }
28
34
  }