@lucaapp/service-utils 5.10.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ import type { CustomTopicPoisonHandler, GenericKafkaEvent } from './types';
2
+ type PoisonHandlerDeps = {
3
+ logger: {
4
+ error: (obj: Record<string, unknown>, msg: string) => void;
5
+ };
6
+ produceCustom: (topicName: string, key: string, value: GenericKafkaEvent) => Promise<void>;
7
+ };
8
+ /**
9
+ * Creates a poison handler that logs the error and produces the raw
10
+ * payload to a DLQ topic named `${topicName}-dlq`.
11
+ */
12
+ declare const createDefaultPoisonHandler: (topicName: string, deps: PoisonHandlerDeps) => CustomTopicPoisonHandler;
13
+ export { createDefaultPoisonHandler };
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createDefaultPoisonHandler = void 0;
4
+ /**
5
+ * Creates a poison handler that logs the error and produces the raw
6
+ * payload to a DLQ topic named `${topicName}-dlq`.
7
+ */
8
+ const createDefaultPoisonHandler = (topicName, deps) => {
9
+ const dlqTopicName = `${topicName}-dlq`;
10
+ return async (ctx) => {
11
+ const errorMessage = ctx.error instanceof Error ? ctx.error.message : String(ctx.error);
12
+ deps.logger.error({
13
+ topic: topicName,
14
+ dlqTopic: dlqTopicName,
15
+ partition: ctx.partition,
16
+ error: errorMessage,
17
+ key: ctx.message.key?.toString(),
18
+ }, 'Poison message detected, forwarding to DLQ');
19
+ try {
20
+ await deps.produceCustom(dlqTopicName, ctx.message.key?.toString() ?? 'unknown', {
21
+ id: 'dlq',
22
+ type: 'create',
23
+ entity: {
24
+ rawPayload: ctx.rawPayloadUtf8,
25
+ error: errorMessage,
26
+ originalTopic: ctx.primaryTopicFullName,
27
+ partition: ctx.partition,
28
+ timestamp: new Date().toISOString(),
29
+ },
30
+ });
31
+ }
32
+ catch (dlqError) {
33
+ deps.logger.error({ error: dlqError, topic: dlqTopicName }, 'Failed to produce to DLQ topic');
34
+ }
35
+ };
36
+ };
37
+ exports.createDefaultPoisonHandler = createDefaultPoisonHandler;
@@ -1,3 +1,3 @@
1
1
  export { KafkaClient } from './kafkaClient';
2
2
  export { KafkaTopic } from './events';
3
- export type { KafkaEvent, GenericKafkaEvent, EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, CustomTopicConfig, } from './types';
3
+ export type { KafkaEvent, GenericKafkaEvent, EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, CustomTopicConfig, CustomTopicPoisonContext, CustomTopicPoisonHandler, } from './types';
@@ -1,7 +1,7 @@
1
1
  import { Consumer } from 'kafkajs';
2
2
  import { Logger } from 'pino';
3
3
  import { ServiceIdentity } from '../serviceIdentity';
4
- import type { EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, KafkaEvent, GenericKafkaEvent, CustomTopicConfig } from './types';
4
+ import type { EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, KafkaEvent, GenericKafkaEvent, CustomTopicConfig, CustomTopicPoisonHandler } from './types';
5
5
  import { KafkaTopic } from './events';
6
6
  declare class KafkaClient {
7
7
  private readonly environment;
@@ -42,7 +42,8 @@ declare class KafkaClient {
42
42
  /**
43
43
  * Consume messages from custom topic
44
44
  */
45
- consumeCustom: <T = unknown>(topicName: string, handler: GenericEventPayloadHandler<T>, fromBeginning?: boolean) => Promise<Consumer>;
45
+ consumeCustom: <T = unknown>(topicName: string, handler: GenericEventPayloadHandler<T>, fromBeginning?: boolean, onPoison?: boolean | CustomTopicPoisonHandler) => Promise<Consumer>;
46
+ private defaultPoisonHandler;
46
47
  private encryptCustomValue;
47
48
  private decryptCustomValue;
48
49
  private parseCustomValue;
@@ -38,6 +38,7 @@ const kafkajs_1 = require("kafkajs");
38
38
  const jose = __importStar(require("jose"));
39
39
  const util_1 = require("util");
40
40
  const serviceIdentity_1 = require("../serviceIdentity");
41
+ const defaultPoisonHandler_1 = require("./defaultPoisonHandler");
41
42
  const events_1 = require("./events");
42
43
  const utils_1 = require("../../utils/utils");
43
44
  const metrics_1 = require("../metrics");
@@ -331,7 +332,10 @@ class KafkaClient {
331
332
  /**
332
333
  * Consume messages from custom topic
333
334
  */
334
- this.consumeCustom = async (topicName, handler, fromBeginning = false) => {
335
+ this.consumeCustom = async (topicName, handler, fromBeginning = false, onPoison) => {
336
+ const poisonHandler = onPoison === true
337
+ ? this.defaultPoisonHandler(topicName)
338
+ : onPoison || undefined;
335
339
  const topic = await this.getCustomTopic(topicName);
336
340
  const groupId = `${this.environment.valueOf()}_${topicName}_${this.serviceIdentity.identityName}`;
337
341
  try {
@@ -345,7 +349,7 @@ class KafkaClient {
345
349
  await consumer.subscribe({ topic, fromBeginning });
346
350
  await consumer.run({
347
351
  autoCommit: true,
348
- eachMessage: async ({ message }) => {
352
+ eachMessage: async ({ message, partition }) => {
349
353
  try {
350
354
  messageConsumedCounter.labels({ topic: topicName, groupId }).inc();
351
355
  // Decrypt if secret was provided
@@ -359,21 +363,35 @@ class KafkaClient {
359
363
  value,
360
364
  timestamp: message.timestamp,
361
365
  }, 'Custom topic record received');
362
- try {
363
- await handler({ ...message, value });
364
- }
365
- catch (error) {
366
- throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
367
- }
366
+ await handler({ ...message, value });
368
367
  messageAcknowledgedCounter
369
368
  .labels({ topic: topicName, groupId })
370
369
  .inc();
371
370
  }
372
371
  catch (error) {
373
- messageConsumedErrorCounter
374
- .labels({ topic: topicName, groupId })
375
- .inc();
376
- throw error;
372
+ if (poisonHandler) {
373
+ this.logger.warn(error, 'Poison message detected, forwarding to DLQ');
374
+ await poisonHandler({
375
+ error,
376
+ decryptFailed: false,
377
+ message,
378
+ partition,
379
+ primaryTopicFullName: topic,
380
+ rawPayloadUtf8: message.value
381
+ ? Buffer.from(message.value).toString('utf8')
382
+ : '',
383
+ });
384
+ messageAcknowledgedCounter
385
+ .labels({ topic: topicName, groupId })
386
+ .inc();
387
+ return;
388
+ }
389
+ else {
390
+ messageConsumedErrorCounter
391
+ .labels({ topic: topicName, groupId })
392
+ .inc();
393
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
394
+ }
377
395
  }
378
396
  },
379
397
  });
@@ -383,6 +401,10 @@ class KafkaClient {
383
401
  throw (0, utils_1.logAndGetError)(this.logger, `Could not create consumer for custom topic=${topicName}`, error);
384
402
  }
385
403
  };
404
+ this.defaultPoisonHandler = (topicName) => (0, defaultPoisonHandler_1.createDefaultPoisonHandler)(topicName, {
405
+ logger: this.logger,
406
+ produceCustom: (t, k, v) => this.produceCustom(t, k, v),
407
+ });
386
408
  this.encryptCustomValue = async (secret, value) => {
387
409
  const jwe = await new jose.CompactEncrypt(new util_1.TextEncoder().encode(value));
388
410
  jwe.setProtectedHeader({ alg: 'A256GCMKW', enc: 'A256GCM' });
@@ -1,6 +1,6 @@
1
1
  import { KafkaTopic, MessageFormats } from './events';
2
2
  import { Environment, Service } from '../serviceIdentity';
3
- import { KafkaMessage } from 'kafkajs';
3
+ import type { KafkaMessage } from 'kafkajs';
4
4
  type KafkaEvent<T extends KafkaTopic> = {
5
5
  id: string;
6
6
  type: 'create' | 'update' | 'soft-destroy' | 'destroy';
@@ -31,4 +31,13 @@ type CustomTopicConfig = {
31
31
  issuer: Service;
32
32
  secret?: string;
33
33
  };
34
- export type { KafkaEvent, GenericKafkaEvent, KafkaConfiguration, EventPayloadHandler, GenericEventPayloadHandler, CustomTopicConfig, };
34
+ type CustomTopicPoisonContext = {
35
+ error: unknown;
36
+ decryptFailed: boolean;
37
+ message: KafkaMessage;
38
+ partition: number;
39
+ primaryTopicFullName: string;
40
+ rawPayloadUtf8: string;
41
+ };
42
+ type CustomTopicPoisonHandler = (ctx: CustomTopicPoisonContext) => Promise<void>;
43
+ export type { KafkaEvent, GenericKafkaEvent, KafkaConfiguration, EventPayloadHandler, GenericEventPayloadHandler, CustomTopicConfig, CustomTopicPoisonContext, CustomTopicPoisonHandler, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.10.0",
3
+ "version": "5.11.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [
@@ -31,7 +31,7 @@
31
31
  "@types/response-time": "^2.3.9",
32
32
  "@types/swagger-ui-express": "4.1.8",
33
33
  "@types/validator": "13.7.1",
34
- "axios": "^1.15.2",
34
+ "axios": "1.16.0",
35
35
  "axios-rate-limit": "^1.4.0",
36
36
  "axios-retry": "^4.5.0",
37
37
  "body-parser": "1.20.4",
@@ -97,7 +97,8 @@
97
97
  "node-forge": "1.4.0",
98
98
  "uuid": "^14.0.0",
99
99
  "follow-redirects": "^1.16.0",
100
- "axios": "^1.15.2",
101
- "path-to-regexp": "0.1.13"
100
+ "axios": "1.16.0",
101
+ "path-to-regexp": "0.1.13",
102
+ "ip-address": "10.1.1"
102
103
  }
103
104
  }