@mhmdhammoud/meritt-utils 1.6.1 → 1.6.2

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 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const elastic_transport_1 = require("../lib/elastic-transport");
4
+ const elasticsearch_1 = require("@elastic/elasticsearch");
5
+ jest.mock('@elastic/elasticsearch', () => ({
6
+ Client: jest.fn(),
7
+ }));
8
+ const MockedClient = elasticsearch_1.Client;
9
+ const writeLog = (transport, doc) => transport.write(`${JSON.stringify(doc)}\n`);
10
+ const waitForEvent = (emitter, eventName) => new Promise((resolve) => {
11
+ emitter.once(eventName, (value) => resolve(value));
12
+ });
13
+ describe('elastic transport resilience', () => {
14
+ beforeEach(() => {
15
+ jest.useRealTimers();
16
+ MockedClient.mockReset();
17
+ });
18
+ test('reports bulk failures without emitting terminal stream errors', async () => {
19
+ const bulk = jest
20
+ .fn()
21
+ .mockRejectedValueOnce(new Error('ES unavailable'))
22
+ .mockResolvedValueOnce({ errors: false, items: [] });
23
+ MockedClient.mockImplementation(() => ({ bulk }));
24
+ const transport = (0, elastic_transport_1.createElasticTransport)({
25
+ index: 'logs',
26
+ flushBytes: 1,
27
+ flushInterval: 10000,
28
+ requestTimeout: 10,
29
+ });
30
+ const streamError = jest.fn();
31
+ transport.on('error', streamError);
32
+ const failed = waitForEvent(transport, 'bulkError');
33
+ writeLog(transport, { message: 'first' });
34
+ await failed;
35
+ const inserted = waitForEvent(transport, 'insert');
36
+ writeLog(transport, { message: 'second' });
37
+ await expect(inserted).resolves.toEqual({ successful: 1, failed: 0 });
38
+ expect(streamError).not.toHaveBeenCalled();
39
+ expect(bulk).toHaveBeenCalledTimes(2);
40
+ });
41
+ test('watchdog releases a stuck bulk request so later logs can flush', async () => {
42
+ jest.useFakeTimers();
43
+ const bulk = jest
44
+ .fn()
45
+ .mockReturnValueOnce(new Promise(() => undefined))
46
+ .mockResolvedValueOnce({ errors: false, items: [] });
47
+ MockedClient.mockImplementation(() => ({ bulk }));
48
+ const transport = (0, elastic_transport_1.createElasticTransport)({
49
+ index: 'logs',
50
+ flushBytes: 1,
51
+ flushInterval: 10000,
52
+ requestTimeout: 1,
53
+ });
54
+ const timedOut = waitForEvent(transport, 'bulkError');
55
+ writeLog(transport, { message: 'stuck' });
56
+ await jest.advanceTimersByTimeAsync(5001);
57
+ await expect(timedOut).resolves.toMatchObject({
58
+ message: 'Elasticsearch bulk request timed out after 5001ms',
59
+ });
60
+ const inserted = waitForEvent(transport, 'insert');
61
+ writeLog(transport, { message: 'recovered' });
62
+ await expect(inserted).resolves.toEqual({ successful: 1, failed: 0 });
63
+ expect(bulk).toHaveBeenCalledTimes(2);
64
+ });
65
+ });
@@ -24,4 +24,8 @@ export interface ElasticTransportOptions extends Pick<ClientOptions, 'node' | 'a
24
24
  rejectUnauthorized?: boolean;
25
25
  tls?: ClientOptions['tls'];
26
26
  }
27
- export declare const createElasticTransport: (opts?: ElasticTransportOptions) => NodeJS.ReadWriteStream;
27
+ export interface ElasticTransportStream extends NodeJS.ReadWriteStream {
28
+ flush: () => Promise<void>;
29
+ close: () => Promise<void>;
30
+ }
31
+ export declare const createElasticTransport: (opts?: ElasticTransportOptions) => ElasticTransportStream;
@@ -17,6 +17,7 @@ exports.createElasticTransport = void 0;
17
17
  // eslint-disable-next-line @typescript-eslint/no-require-imports
18
18
  const split = require('split2');
19
19
  const elasticsearch_1 = require("@elastic/elasticsearch");
20
+ const createTimeoutError = (timeoutMs) => new Error(`Elasticsearch bulk request timed out after ${timeoutMs}ms`);
20
21
  function setDateTimeString(value) {
21
22
  if (value !== null && typeof value === 'object' && 'time' in value) {
22
23
  const t = value.time;
@@ -34,13 +35,15 @@ function getIndexName(index, time) {
34
35
  return index.replace('%{DATE}', time.substring(0, 10));
35
36
  }
36
37
  function createBulkSender(opts, client, splitter) {
37
- var _a, _b, _c, _d, _e, _f, _g;
38
+ var _a, _b, _c, _d, _e, _f, _g, _h;
38
39
  const esVersion = Number((_b = (_a = opts.esVersion) !== null && _a !== void 0 ? _a : opts['es-version']) !== null && _b !== void 0 ? _b : 7);
39
40
  const index = (_c = opts.index) !== null && _c !== void 0 ? _c : 'pino';
40
41
  const buildIndexName = typeof index === 'function' ? index : null;
41
42
  const opType = esVersion >= 7 ? undefined : undefined;
42
43
  const flushBytes = (_e = (_d = opts.flushBytes) !== null && _d !== void 0 ? _d : opts['flush-bytes']) !== null && _e !== void 0 ? _e : 1000;
43
44
  const flushInterval = (_g = (_f = opts.flushInterval) !== null && _f !== void 0 ? _f : opts['flush-interval']) !== null && _g !== void 0 ? _g : 3000;
45
+ const requestTimeout = (_h = opts.requestTimeout) !== null && _h !== void 0 ? _h : 30000;
46
+ const bulkWatchdogTimeout = requestTimeout + 5000;
44
47
  let buffer = [];
45
48
  let bufferedBytes = 0;
46
49
  let timer;
@@ -100,6 +103,40 @@ function createBulkSender(opts, client, splitter) {
100
103
  error.cause = cause;
101
104
  splitter.emit('insertError', error);
102
105
  };
106
+ const emitBulkError = (err) => {
107
+ // Do not emit the standard stream "error" event for retryable bulk
108
+ // failures. Some stream consumers treat it as terminal, which can leave
109
+ // Pino writing into a poisoned stream while the process continues running.
110
+ splitter.emit('bulkError', err);
111
+ };
112
+ const bulkWithWatchdog = async (operations) => {
113
+ let timeout;
114
+ const bulkPromise = client.bulk({
115
+ operations,
116
+ refresh: false,
117
+ timeout: `${requestTimeout}ms`,
118
+ });
119
+ try {
120
+ return await Promise.race([
121
+ bulkPromise,
122
+ new Promise((_, reject) => {
123
+ var _a;
124
+ timeout = setTimeout(() => {
125
+ reject(createTimeoutError(bulkWatchdogTimeout));
126
+ }, bulkWatchdogTimeout);
127
+ (_a = timeout.unref) === null || _a === void 0 ? void 0 : _a.call(timeout);
128
+ }),
129
+ ]);
130
+ }
131
+ finally {
132
+ if (timeout) {
133
+ clearTimeout(timeout);
134
+ }
135
+ // If the watchdog wins, the original request may reject later. Consume
136
+ // that rejection so a stale request cannot crash the app.
137
+ bulkPromise.catch(() => undefined);
138
+ }
139
+ };
103
140
  const flush = async () => {
104
141
  var _a, _b;
105
142
  if (isFlushing) {
@@ -116,12 +153,7 @@ function createBulkSender(opts, client, splitter) {
116
153
  bufferedBytes = 0;
117
154
  try {
118
155
  const operations = batch.flatMap(buildOperation);
119
- const response = await client.bulk({
120
- operations,
121
- refresh: false,
122
- timeout: opts.requestTimeout ? `${opts.requestTimeout}ms` : undefined,
123
- });
124
- const body = response;
156
+ const body = await bulkWithWatchdog(operations);
125
157
  if (body.errors && Array.isArray(body.items)) {
126
158
  body.items.forEach((item, index) => {
127
159
  const result = Object.values(item)[0];
@@ -132,11 +164,13 @@ function createBulkSender(opts, client, splitter) {
132
164
  }
133
165
  splitter.emit('insert', {
134
166
  successful: batch.length,
135
- failed: body.errors ? ((_b = (_a = body.items) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) : 0,
167
+ failed: body.errors
168
+ ? ((_b = (_a = body.items) === null || _a === void 0 ? void 0 : _a.filter((item) => { var _a; return (_a = Object.values(item)[0]) === null || _a === void 0 ? void 0 : _a.error; }).length) !== null && _b !== void 0 ? _b : 0)
169
+ : 0,
136
170
  });
137
171
  }
138
172
  catch (err) {
139
- splitter.emit('error', err);
173
+ emitBulkError(err);
140
174
  // Drop the failed batch instead of wedging the stream. The next log line
141
175
  // creates a fresh bulk request and can recover without a process restart.
142
176
  batch.forEach((doc) => emitDroppedDocument(doc, err));
@@ -226,11 +260,13 @@ const createElasticTransport = (opts = {}) => {
226
260
  void bulkSender.close();
227
261
  });
228
262
  const splitterWithDestroy = splitter;
263
+ splitterWithDestroy.flush = bulkSender.flush;
264
+ splitterWithDestroy.close = bulkSender.close;
229
265
  const originalDestroy = splitterWithDestroy.destroy.bind(splitterWithDestroy);
230
266
  splitterWithDestroy.destroy = function (err) {
231
267
  void bulkSender.close();
232
268
  originalDestroy(err);
233
269
  };
234
- return splitter;
270
+ return splitterWithDestroy;
235
271
  };
236
272
  exports.createElasticTransport = createElasticTransport;
@@ -219,8 +219,12 @@ function registerShutdownHandlers() {
219
219
  clearTimeout(timeout);
220
220
  resolve();
221
221
  });
222
- // Now trigger the flush
223
- esTransport === null || esTransport === void 0 ? void 0 : esTransport.end();
222
+ esTransport === null || esTransport === void 0 ? void 0 : esTransport.flush().catch((error) => {
223
+ console.error(`[Logger] Error flushing logs on ${signal}:`, error);
224
+ }).finally(() => {
225
+ // Now trigger stream shutdown
226
+ esTransport === null || esTransport === void 0 ? void 0 : esTransport.end();
227
+ });
224
228
  });
225
229
  }
226
230
  catch (error) {
@@ -295,7 +299,7 @@ function getLogger(elasticConfig) {
295
299
  // Create transport with connection lifecycle fix (pino-elasticsearch #140)
296
300
  esTransport = (0, elastic_transport_1.createElasticTransport)(esConfig);
297
301
  // Handle Elasticsearch connection errors
298
- esTransport.on('error', (err) => {
302
+ esTransport.on('bulkError', (err) => {
299
303
  console.error('[Logger] Elasticsearch transport error:', err.message);
300
304
  console.error('[Logger] Logs may not be reaching Kibana. Check Elasticsearch connection.');
301
305
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhmdhammoud/meritt-utils",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
@@ -0,0 +1,94 @@
1
+ import { createElasticTransport } from '../lib/elastic-transport'
2
+ import { Client } from '@elastic/elasticsearch'
3
+ import { EventEmitter } from 'events'
4
+
5
+ jest.mock('@elastic/elasticsearch', () => ({
6
+ Client: jest.fn(),
7
+ }))
8
+
9
+ const MockedClient = Client as jest.Mock
10
+
11
+ const writeLog = (
12
+ transport: NodeJS.WritableStream,
13
+ doc: Record<string, unknown>
14
+ ) => transport.write(`${JSON.stringify(doc)}\n`)
15
+
16
+ const waitForEvent = <T = unknown>(
17
+ emitter: EventEmitter,
18
+ eventName: string
19
+ ): Promise<T> =>
20
+ new Promise((resolve) => {
21
+ emitter.once(eventName, (value) => resolve(value as T))
22
+ })
23
+
24
+ describe('elastic transport resilience', () => {
25
+ beforeEach(() => {
26
+ jest.useRealTimers()
27
+ MockedClient.mockReset()
28
+ })
29
+
30
+ test('reports bulk failures without emitting terminal stream errors', async () => {
31
+ const bulk = jest
32
+ .fn()
33
+ .mockRejectedValueOnce(new Error('ES unavailable'))
34
+ .mockResolvedValueOnce({ errors: false, items: [] })
35
+ MockedClient.mockImplementation(() => ({ bulk }))
36
+
37
+ const transport = createElasticTransport({
38
+ index: 'logs',
39
+ flushBytes: 1,
40
+ flushInterval: 10000,
41
+ requestTimeout: 10,
42
+ })
43
+ const streamError = jest.fn()
44
+ transport.on('error', streamError)
45
+
46
+ const failed = waitForEvent<Error>(transport, 'bulkError')
47
+ writeLog(transport, { message: 'first' })
48
+ await failed
49
+
50
+ const inserted = waitForEvent<{ successful: number; failed: number }>(
51
+ transport,
52
+ 'insert'
53
+ )
54
+ writeLog(transport, { message: 'second' })
55
+
56
+ await expect(inserted).resolves.toEqual({ successful: 1, failed: 0 })
57
+ expect(streamError).not.toHaveBeenCalled()
58
+ expect(bulk).toHaveBeenCalledTimes(2)
59
+ })
60
+
61
+ test('watchdog releases a stuck bulk request so later logs can flush', async () => {
62
+ jest.useFakeTimers()
63
+
64
+ const bulk = jest
65
+ .fn()
66
+ .mockReturnValueOnce(new Promise(() => undefined))
67
+ .mockResolvedValueOnce({ errors: false, items: [] })
68
+ MockedClient.mockImplementation(() => ({ bulk }))
69
+
70
+ const transport = createElasticTransport({
71
+ index: 'logs',
72
+ flushBytes: 1,
73
+ flushInterval: 10000,
74
+ requestTimeout: 1,
75
+ })
76
+
77
+ const timedOut = waitForEvent<Error>(transport, 'bulkError')
78
+ writeLog(transport, { message: 'stuck' })
79
+
80
+ await jest.advanceTimersByTimeAsync(5001)
81
+ await expect(timedOut).resolves.toMatchObject({
82
+ message: 'Elasticsearch bulk request timed out after 5001ms',
83
+ })
84
+
85
+ const inserted = waitForEvent<{ successful: number; failed: number }>(
86
+ transport,
87
+ 'insert'
88
+ )
89
+ writeLog(transport, { message: 'recovered' })
90
+
91
+ await expect(inserted).resolves.toEqual({ successful: 1, failed: 0 })
92
+ expect(bulk).toHaveBeenCalledTimes(2)
93
+ })
94
+ })
@@ -49,6 +49,14 @@ interface LogDocument {
49
49
  [k: string]: unknown
50
50
  }
51
51
 
52
+ export interface ElasticTransportStream extends NodeJS.ReadWriteStream {
53
+ flush: () => Promise<void>
54
+ close: () => Promise<void>
55
+ }
56
+
57
+ const createTimeoutError = (timeoutMs: number): Error =>
58
+ new Error(`Elasticsearch bulk request timed out after ${timeoutMs}ms`)
59
+
52
60
  function setDateTimeString(value: unknown): string {
53
61
  if (value !== null && typeof value === 'object' && 'time' in value) {
54
62
  const t = (value as { time: unknown }).time
@@ -87,6 +95,8 @@ function createBulkSender(
87
95
  const opType = esVersion >= 7 ? undefined : undefined
88
96
  const flushBytes = opts.flushBytes ?? opts['flush-bytes'] ?? 1000
89
97
  const flushInterval = opts.flushInterval ?? opts['flush-interval'] ?? 3000
98
+ const requestTimeout = opts.requestTimeout ?? 30000
99
+ const bulkWatchdogTimeout = requestTimeout + 5000
90
100
 
91
101
  let buffer: unknown[] = []
92
102
  let bufferedBytes = 0
@@ -154,6 +164,49 @@ function createBulkSender(
154
164
  splitter.emit('insertError', error)
155
165
  }
156
166
 
167
+ const emitBulkError = (err: unknown) => {
168
+ // Do not emit the standard stream "error" event for retryable bulk
169
+ // failures. Some stream consumers treat it as terminal, which can leave
170
+ // Pino writing into a poisoned stream while the process continues running.
171
+ splitter.emit('bulkError', err)
172
+ }
173
+
174
+ const bulkWithWatchdog = async (
175
+ operations: unknown[]
176
+ ): Promise<{
177
+ errors?: boolean
178
+ items?: Array<Record<string, { error?: unknown }>>
179
+ }> => {
180
+ let timeout: NodeJS.Timeout | undefined
181
+ const bulkPromise = client.bulk({
182
+ operations,
183
+ refresh: false,
184
+ timeout: `${requestTimeout}ms`,
185
+ }) as Promise<{
186
+ errors?: boolean
187
+ items?: Array<Record<string, { error?: unknown }>>
188
+ }>
189
+
190
+ try {
191
+ return await Promise.race([
192
+ bulkPromise,
193
+ new Promise<never>((_, reject) => {
194
+ timeout = setTimeout(() => {
195
+ reject(createTimeoutError(bulkWatchdogTimeout))
196
+ }, bulkWatchdogTimeout)
197
+ timeout.unref?.()
198
+ }),
199
+ ])
200
+ } finally {
201
+ if (timeout) {
202
+ clearTimeout(timeout)
203
+ }
204
+ // If the watchdog wins, the original request may reject later. Consume
205
+ // that rejection so a stale request cannot crash the app.
206
+ bulkPromise.catch(() => undefined)
207
+ }
208
+ }
209
+
157
210
  const flush = async (): Promise<void> => {
158
211
  if (isFlushing) {
159
212
  flushAgain = true
@@ -171,16 +224,7 @@ function createBulkSender(
171
224
 
172
225
  try {
173
226
  const operations = batch.flatMap(buildOperation)
174
- const response = await client.bulk({
175
- operations,
176
- refresh: false,
177
- timeout: opts.requestTimeout ? `${opts.requestTimeout}ms` : undefined,
178
- })
179
-
180
- const body = response as {
181
- errors?: boolean
182
- items?: Array<Record<string, { error?: unknown }>>
183
- }
227
+ const body = await bulkWithWatchdog(operations)
184
228
  if (body.errors && Array.isArray(body.items)) {
185
229
  body.items.forEach((item, index) => {
186
230
  const result = Object.values(item)[0]
@@ -192,10 +236,13 @@ function createBulkSender(
192
236
 
193
237
  splitter.emit('insert', {
194
238
  successful: batch.length,
195
- failed: body.errors ? (body.items?.length ?? 0) : 0,
239
+ failed: body.errors
240
+ ? (body.items?.filter((item) => Object.values(item)[0]?.error)
241
+ .length ?? 0)
242
+ : 0,
196
243
  })
197
244
  } catch (err) {
198
- splitter.emit('error', err)
245
+ emitBulkError(err)
199
246
  // Drop the failed batch instead of wedging the stream. The next log line
200
247
  // creates a fresh bulk request and can recover without a process restart.
201
248
  batch.forEach((doc) => emitDroppedDocument(doc, err))
@@ -231,7 +278,7 @@ function createBulkSender(
231
278
 
232
279
  export const createElasticTransport = (
233
280
  opts: ElasticTransportOptions = {}
234
- ): NodeJS.ReadWriteStream => {
281
+ ): ElasticTransportStream => {
235
282
  const splitter = split(
236
283
  function (this: NodeJS.ReadWriteStream, line: string) {
237
284
  let value: unknown
@@ -294,14 +341,16 @@ export const createElasticTransport = (
294
341
  void bulkSender.close()
295
342
  })
296
343
 
297
- const splitterWithDestroy = splitter as NodeJS.ReadWriteStream & {
344
+ const splitterWithDestroy = splitter as ElasticTransportStream & {
298
345
  destroy: (err?: Error) => void
299
346
  }
347
+ splitterWithDestroy.flush = bulkSender.flush
348
+ splitterWithDestroy.close = bulkSender.close
300
349
  const originalDestroy = splitterWithDestroy.destroy.bind(splitterWithDestroy)
301
350
  splitterWithDestroy.destroy = function (err?: Error) {
302
351
  void bulkSender.close()
303
352
  originalDestroy(err)
304
353
  }
305
354
 
306
- return splitter
355
+ return splitterWithDestroy
307
356
  }
package/src/lib/logger.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { Logger as PinoLogger, pino, stdTimeFunctions } from 'pino'
2
2
  import * as dotenv from 'dotenv'
3
3
  import { hostname } from 'os'
4
- import { createElasticTransport } from './elastic-transport'
4
+ import {
5
+ createElasticTransport,
6
+ ElasticTransportStream,
7
+ } from './elastic-transport'
5
8
  import { getTraceContext } from './trace-store'
6
9
  import { LOG_LEVEL, LogEvent, ElasticConfig } from '../types'
7
10
 
@@ -140,7 +143,7 @@ let pinoLogger: PinoLogger
140
143
  /**
141
144
  * Elasticsearch transport instance - kept for cleanup
142
145
  */
143
- let esTransport: NodeJS.ReadWriteStream | null = null
146
+ let esTransport: ElasticTransportStream | null = null
144
147
 
145
148
  /**
146
149
  * Flag to track if shutdown handlers are registered
@@ -233,8 +236,15 @@ function registerShutdownHandlers(): void {
233
236
  resolve()
234
237
  })
235
238
 
236
- // Now trigger the flush
237
- esTransport?.end()
239
+ esTransport
240
+ ?.flush()
241
+ .catch((error) => {
242
+ console.error(`[Logger] Error flushing logs on ${signal}:`, error)
243
+ })
244
+ .finally(() => {
245
+ // Now trigger stream shutdown
246
+ esTransport?.end()
247
+ })
238
248
  })
239
249
  } catch (error) {
240
250
  console.error(`[Logger] Error flushing logs on ${signal}:`, error)
@@ -333,7 +343,7 @@ function getLogger(elasticConfig?: ElasticConfig): PinoLogger {
333
343
  esTransport = createElasticTransport(esConfig)
334
344
 
335
345
  // Handle Elasticsearch connection errors
336
- esTransport.on('error', (err: Error) => {
346
+ esTransport.on('bulkError', (err: Error) => {
337
347
  console.error('[Logger] Elasticsearch transport error:', err.message)
338
348
  console.error(
339
349
  '[Logger] Logs may not be reaching Kibana. Check Elasticsearch connection.'