@mhmdhammoud/meritt-utils 1.6.0 → 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;
@@ -33,42 +34,174 @@ function getIndexName(index, time) {
33
34
  }
34
35
  return index.replace('%{DATE}', time.substring(0, 10));
35
36
  }
36
- function initializeBulkHandler(opts, client, splitter, onFatalError) {
37
- var _a, _b, _c, _d, _e, _f, _g;
37
+ function createBulkSender(opts, client, splitter) {
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;
43
+ const flushBytes = (_e = (_d = opts.flushBytes) !== null && _d !== void 0 ? _d : opts['flush-bytes']) !== null && _e !== void 0 ? _e : 1000;
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;
47
+ let buffer = [];
48
+ let bufferedBytes = 0;
49
+ let timer;
50
+ let isFlushing = false;
51
+ let flushAgain = false;
42
52
  const indexName = (time = new Date().toISOString()) => buildIndexName ? buildIndexName(time) : getIndexName(index, time);
43
- const bulkInsert = client.helpers.bulk({
44
- datasource: splitter,
45
- flushBytes: (_e = (_d = opts.flushBytes) !== null && _d !== void 0 ? _d : opts['flush-bytes']) !== null && _e !== void 0 ? _e : 1000,
46
- flushInterval: (_g = (_f = opts.flushInterval) !== null && _f !== void 0 ? _f : opts['flush-interval']) !== null && _g !== void 0 ? _g : 3000,
47
- refreshOnCompletion: false,
48
- onDocument(doc) {
49
- var _a, _b;
53
+ const clearFlushTimer = () => {
54
+ if (timer) {
55
+ clearTimeout(timer);
56
+ timer = undefined;
57
+ }
58
+ };
59
+ const scheduleFlush = () => {
60
+ var _a;
61
+ if (timer || buffer.length === 0) {
62
+ return;
63
+ }
64
+ timer = setTimeout(() => {
65
+ timer = undefined;
66
+ void flush();
67
+ }, flushInterval);
68
+ (_a = timer.unref) === null || _a === void 0 ? void 0 : _a.call(timer);
69
+ };
70
+ const buildOperation = (doc) => {
71
+ var _a, _b;
72
+ try {
50
73
  const d = doc;
51
74
  const date = (_b = (_a = d.time) !== null && _a !== void 0 ? _a : d['@timestamp']) !== null && _b !== void 0 ? _b : new Date().toISOString();
52
75
  if (opType === 'create') {
53
76
  d['@timestamp'] = date;
54
77
  }
55
- return {
56
- index: {
57
- _index: indexName(date),
58
- op_type: opType,
78
+ return [
79
+ {
80
+ index: {
81
+ _index: indexName(date),
82
+ op_type: opType,
83
+ },
59
84
  },
60
- };
85
+ doc,
86
+ ];
87
+ }
88
+ catch (_c) {
89
+ return [
90
+ {
91
+ index: {
92
+ _index: indexName(),
93
+ op_type: opType,
94
+ },
95
+ },
96
+ doc,
97
+ ];
98
+ }
99
+ };
100
+ const emitDroppedDocument = (doc, cause) => {
101
+ const error = new Error('Dropped document');
102
+ error.document = doc;
103
+ error.cause = cause;
104
+ splitter.emit('insertError', error);
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
+ };
140
+ const flush = async () => {
141
+ var _a, _b;
142
+ if (isFlushing) {
143
+ flushAgain = true;
144
+ return;
145
+ }
146
+ clearFlushTimer();
147
+ if (buffer.length === 0) {
148
+ return;
149
+ }
150
+ isFlushing = true;
151
+ const batch = buffer;
152
+ buffer = [];
153
+ bufferedBytes = 0;
154
+ try {
155
+ const operations = batch.flatMap(buildOperation);
156
+ const body = await bulkWithWatchdog(operations);
157
+ if (body.errors && Array.isArray(body.items)) {
158
+ body.items.forEach((item, index) => {
159
+ const result = Object.values(item)[0];
160
+ if (result === null || result === void 0 ? void 0 : result.error) {
161
+ emitDroppedDocument(batch[index], result.error);
162
+ }
163
+ });
164
+ }
165
+ splitter.emit('insert', {
166
+ successful: batch.length,
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,
170
+ });
171
+ }
172
+ catch (err) {
173
+ emitBulkError(err);
174
+ // Drop the failed batch instead of wedging the stream. The next log line
175
+ // creates a fresh bulk request and can recover without a process restart.
176
+ batch.forEach((doc) => emitDroppedDocument(doc, err));
177
+ }
178
+ finally {
179
+ isFlushing = false;
180
+ if (flushAgain || buffer.length > 0) {
181
+ flushAgain = false;
182
+ scheduleFlush();
183
+ if (bufferedBytes >= flushBytes) {
184
+ void flush();
185
+ }
186
+ }
187
+ }
188
+ };
189
+ return {
190
+ add(doc) {
191
+ buffer.push(doc);
192
+ bufferedBytes += Buffer.byteLength(JSON.stringify(doc));
193
+ if (bufferedBytes >= flushBytes) {
194
+ void flush();
195
+ return;
196
+ }
197
+ scheduleFlush();
61
198
  },
62
- onDrop(doc) {
63
- const error = new Error('Dropped document');
64
- error.document = doc;
65
- splitter.emit('insertError', error);
199
+ flush,
200
+ async close() {
201
+ clearFlushTimer();
202
+ await flush();
66
203
  },
67
- });
68
- bulkInsert.then((stats) => splitter.emit('insert', stats), (err) => {
69
- splitter.emit('error', err);
70
- onFatalError(err);
71
- });
204
+ };
72
205
  }
73
206
  const createElasticTransport = (opts = {}) => {
74
207
  const splitter = split(function (line) {
@@ -119,56 +252,21 @@ const createElasticTransport = (opts = {}) => {
119
252
  clientOpts.ConnectionPool = opts.ConnectionPool;
120
253
  }
121
254
  const client = new elasticsearch_1.Client(clientOpts);
122
- // CRITICAL FIX (pino-elasticsearch issues #140/#72): after retries are
123
- // exhausted the bulk helper can stop consuming the stream while the process
124
- // stays alive. Keep exactly one helper active and replace it after fatal
125
- // helper failures instead of waiting for a server restart.
126
- let isBulkHandlerActive = false;
127
- let isRestartScheduled = false;
128
- let isTransportClosed = false;
129
- const pool = client.connectionPool;
255
+ const bulkSender = createBulkSender(opts, client, splitter);
256
+ splitter.on('data', (doc) => {
257
+ bulkSender.add(doc);
258
+ });
259
+ splitter.on('finish', () => {
260
+ void bulkSender.close();
261
+ });
130
262
  const splitterWithDestroy = splitter;
263
+ splitterWithDestroy.flush = bulkSender.flush;
264
+ splitterWithDestroy.close = bulkSender.close;
131
265
  const originalDestroy = splitterWithDestroy.destroy.bind(splitterWithDestroy);
132
- const startBulkHandler = () => {
133
- if (isTransportClosed || isBulkHandlerActive) {
134
- return;
135
- }
136
- isBulkHandlerActive = true;
137
- initializeBulkHandler(opts, client, splitter, () => {
138
- isBulkHandlerActive = false;
139
- scheduleBulkHandlerRestart();
140
- });
141
- };
142
- const scheduleBulkHandlerRestart = () => {
143
- var _a, _b, _c;
144
- if (isTransportClosed || isRestartScheduled) {
145
- return;
146
- }
147
- isRestartScheduled = true;
148
- if (typeof pool.resurrect === 'function') {
149
- pool.resurrect({ name: 'elasticsearch-js' });
150
- }
151
- const retryDelayMs = Math.min(Number((_b = (_a = opts.flushInterval) !== null && _a !== void 0 ? _a : opts['flush-interval']) !== null && _b !== void 0 ? _b : 3000), 5000);
152
- const timer = setTimeout(() => {
153
- isRestartScheduled = false;
154
- startBulkHandler();
155
- }, retryDelayMs);
156
- (_c = timer.unref) === null || _c === void 0 ? void 0 : _c.call(timer);
157
- };
158
266
  splitterWithDestroy.destroy = function (err) {
159
- if (err && !isTransportClosed) {
160
- scheduleBulkHandlerRestart();
161
- return;
162
- }
163
- isTransportClosed = true;
267
+ void bulkSender.close();
164
268
  originalDestroy(err);
165
269
  };
166
- client.diagnostic.on('resurrect', () => {
167
- if (!isBulkHandlerActive) {
168
- scheduleBulkHandlerRestart();
169
- }
170
- });
171
- startBulkHandler();
172
- return splitter;
270
+ return splitterWithDestroy;
173
271
  };
174
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.0",
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
+ })
@@ -17,7 +17,6 @@ const split = require('split2') as (
17
17
  fn: (line: string) => unknown,
18
18
  opts?: { autoDestroy?: boolean }
19
19
  ) => NodeJS.ReadWriteStream
20
- import { Readable } from 'stream'
21
20
  import { Client } from '@elastic/elasticsearch'
22
21
  import type { ClientOptions } from '@elastic/elasticsearch'
23
22
 
@@ -50,6 +49,14 @@ interface LogDocument {
50
49
  [k: string]: unknown
51
50
  }
52
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
+
53
60
  function setDateTimeString(value: unknown): string {
54
61
  if (value !== null && typeof value === 'object' && 'time' in value) {
55
62
  const t = (value as { time: unknown }).time
@@ -73,59 +80,205 @@ function getIndexName(
73
80
  return index.replace('%{DATE}', time.substring(0, 10))
74
81
  }
75
82
 
76
- function initializeBulkHandler(
83
+ function createBulkSender(
77
84
  opts: ElasticTransportOptions,
78
85
  client: Client,
79
- splitter: NodeJS.ReadWriteStream,
80
- onFatalError: (err: Error) => void
81
- ): void {
86
+ splitter: NodeJS.ReadWriteStream
87
+ ): {
88
+ add: (doc: unknown) => void
89
+ flush: () => Promise<void>
90
+ close: () => Promise<void>
91
+ } {
82
92
  const esVersion = Number(opts.esVersion ?? opts['es-version'] ?? 7)
83
93
  const index = opts.index ?? 'pino'
84
94
  const buildIndexName = typeof index === 'function' ? index : null
85
95
  const opType = esVersion >= 7 ? undefined : undefined
96
+ const flushBytes = opts.flushBytes ?? opts['flush-bytes'] ?? 1000
97
+ const flushInterval = opts.flushInterval ?? opts['flush-interval'] ?? 3000
98
+ const requestTimeout = opts.requestTimeout ?? 30000
99
+ const bulkWatchdogTimeout = requestTimeout + 5000
100
+
101
+ let buffer: unknown[] = []
102
+ let bufferedBytes = 0
103
+ let timer: NodeJS.Timeout | undefined
104
+ let isFlushing = false
105
+ let flushAgain = false
86
106
 
87
107
  const indexName = (time = new Date().toISOString()) =>
88
108
  buildIndexName ? buildIndexName(time) : getIndexName(index as string, time)
89
109
 
90
- const bulkInsert = client.helpers.bulk({
91
- datasource: splitter as unknown as Readable,
92
- flushBytes: opts.flushBytes ?? opts['flush-bytes'] ?? 1000,
93
- flushInterval: opts.flushInterval ?? opts['flush-interval'] ?? 3000,
94
- refreshOnCompletion: false,
95
- onDocument(doc: unknown) {
110
+ const clearFlushTimer = () => {
111
+ if (timer) {
112
+ clearTimeout(timer)
113
+ timer = undefined
114
+ }
115
+ }
116
+
117
+ const scheduleFlush = () => {
118
+ if (timer || buffer.length === 0) {
119
+ return
120
+ }
121
+ timer = setTimeout(() => {
122
+ timer = undefined
123
+ void flush()
124
+ }, flushInterval)
125
+ timer.unref?.()
126
+ }
127
+
128
+ const buildOperation = (doc: unknown): [Record<string, unknown>, unknown] => {
129
+ try {
96
130
  const d = doc as LogDocument
97
131
  const date = d.time ?? d['@timestamp'] ?? new Date().toISOString()
98
132
  if (opType === 'create') {
99
133
  d['@timestamp'] = date
100
134
  }
101
- return {
102
- index: {
103
- _index: indexName(date),
104
- op_type: opType,
135
+ return [
136
+ {
137
+ index: {
138
+ _index: indexName(date),
139
+ op_type: opType,
140
+ },
141
+ },
142
+ doc,
143
+ ]
144
+ } catch {
145
+ return [
146
+ {
147
+ index: {
148
+ _index: indexName(),
149
+ op_type: opType,
150
+ },
105
151
  },
152
+ doc,
153
+ ]
154
+ }
155
+ }
156
+
157
+ const emitDroppedDocument = (doc: unknown, cause?: unknown) => {
158
+ const error = new Error('Dropped document') as Error & {
159
+ document: unknown
160
+ cause?: unknown
161
+ }
162
+ error.document = doc
163
+ error.cause = cause
164
+ splitter.emit('insertError', error)
165
+ }
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)
106
203
  }
107
- },
108
- onDrop(doc: unknown) {
109
- const error = new Error('Dropped document') as Error & {
110
- document: unknown
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
+
210
+ const flush = async (): Promise<void> => {
211
+ if (isFlushing) {
212
+ flushAgain = true
213
+ return
214
+ }
215
+ clearFlushTimer()
216
+ if (buffer.length === 0) {
217
+ return
218
+ }
219
+
220
+ isFlushing = true
221
+ const batch = buffer
222
+ buffer = []
223
+ bufferedBytes = 0
224
+
225
+ try {
226
+ const operations = batch.flatMap(buildOperation)
227
+ const body = await bulkWithWatchdog(operations)
228
+ if (body.errors && Array.isArray(body.items)) {
229
+ body.items.forEach((item, index) => {
230
+ const result = Object.values(item)[0]
231
+ if (result?.error) {
232
+ emitDroppedDocument(batch[index], result.error)
233
+ }
234
+ })
111
235
  }
112
- error.document = doc
113
- splitter.emit('insertError', error)
114
- },
115
- })
116
236
 
117
- bulkInsert.then(
118
- (stats) => splitter.emit('insert', stats),
119
- (err) => {
120
- splitter.emit('error', err)
121
- onFatalError(err)
237
+ splitter.emit('insert', {
238
+ successful: batch.length,
239
+ failed: body.errors
240
+ ? (body.items?.filter((item) => Object.values(item)[0]?.error)
241
+ .length ?? 0)
242
+ : 0,
243
+ })
244
+ } catch (err) {
245
+ emitBulkError(err)
246
+ // Drop the failed batch instead of wedging the stream. The next log line
247
+ // creates a fresh bulk request and can recover without a process restart.
248
+ batch.forEach((doc) => emitDroppedDocument(doc, err))
249
+ } finally {
250
+ isFlushing = false
251
+ if (flushAgain || buffer.length > 0) {
252
+ flushAgain = false
253
+ scheduleFlush()
254
+ if (bufferedBytes >= flushBytes) {
255
+ void flush()
256
+ }
257
+ }
122
258
  }
123
- )
259
+ }
260
+
261
+ return {
262
+ add(doc: unknown) {
263
+ buffer.push(doc)
264
+ bufferedBytes += Buffer.byteLength(JSON.stringify(doc))
265
+ if (bufferedBytes >= flushBytes) {
266
+ void flush()
267
+ return
268
+ }
269
+ scheduleFlush()
270
+ },
271
+ flush,
272
+ async close() {
273
+ clearFlushTimer()
274
+ await flush()
275
+ },
276
+ }
124
277
  }
125
278
 
126
279
  export const createElasticTransport = (
127
280
  opts: ElasticTransportOptions = {}
128
- ): NodeJS.ReadWriteStream => {
281
+ ): ElasticTransportStream => {
129
282
  const splitter = split(
130
283
  function (this: NodeJS.ReadWriteStream, line: string) {
131
284
  let value: unknown
@@ -179,71 +332,25 @@ export const createElasticTransport = (
179
332
  }
180
333
 
181
334
  const client = new Client(clientOpts)
335
+ const bulkSender = createBulkSender(opts, client, splitter)
182
336
 
183
- // CRITICAL FIX (pino-elasticsearch issues #140/#72): after retries are
184
- // exhausted the bulk helper can stop consuming the stream while the process
185
- // stays alive. Keep exactly one helper active and replace it after fatal
186
- // helper failures instead of waiting for a server restart.
187
- let isBulkHandlerActive = false
188
- let isRestartScheduled = false
189
- let isTransportClosed = false
337
+ splitter.on('data', (doc) => {
338
+ bulkSender.add(doc)
339
+ })
340
+ splitter.on('finish', () => {
341
+ void bulkSender.close()
342
+ })
190
343
 
191
- const pool = client.connectionPool as {
192
- resurrect?: (opts: { name: string }) => void
193
- }
194
- const splitterWithDestroy = splitter as NodeJS.ReadWriteStream & {
344
+ const splitterWithDestroy = splitter as ElasticTransportStream & {
195
345
  destroy: (err?: Error) => void
196
346
  }
347
+ splitterWithDestroy.flush = bulkSender.flush
348
+ splitterWithDestroy.close = bulkSender.close
197
349
  const originalDestroy = splitterWithDestroy.destroy.bind(splitterWithDestroy)
198
-
199
- const startBulkHandler = () => {
200
- if (isTransportClosed || isBulkHandlerActive) {
201
- return
202
- }
203
- isBulkHandlerActive = true
204
- initializeBulkHandler(opts, client, splitter, () => {
205
- isBulkHandlerActive = false
206
- scheduleBulkHandlerRestart()
207
- })
208
- }
209
-
210
- const scheduleBulkHandlerRestart = () => {
211
- if (isTransportClosed || isRestartScheduled) {
212
- return
213
- }
214
- isRestartScheduled = true
215
-
216
- if (typeof pool.resurrect === 'function') {
217
- pool.resurrect({ name: 'elasticsearch-js' })
218
- }
219
-
220
- const retryDelayMs = Math.min(
221
- Number(opts.flushInterval ?? opts['flush-interval'] ?? 3000),
222
- 5000
223
- )
224
- const timer = setTimeout(() => {
225
- isRestartScheduled = false
226
- startBulkHandler()
227
- }, retryDelayMs)
228
- timer.unref?.()
229
- }
230
-
231
350
  splitterWithDestroy.destroy = function (err?: Error) {
232
- if (err && !isTransportClosed) {
233
- scheduleBulkHandlerRestart()
234
- return
235
- }
236
- isTransportClosed = true
351
+ void bulkSender.close()
237
352
  originalDestroy(err)
238
353
  }
239
354
 
240
- client.diagnostic.on('resurrect', () => {
241
- if (!isBulkHandlerActive) {
242
- scheduleBulkHandlerRestart()
243
- }
244
- })
245
-
246
- startBulkHandler()
247
-
248
- return splitter
355
+ return splitterWithDestroy
249
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.'