@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.
- package/dist/__tests__/elastic-transport.test.d.ts +1 -0
- package/dist/__tests__/elastic-transport.test.js +65 -0
- package/dist/lib/elastic-transport.d.ts +5 -1
- package/dist/lib/elastic-transport.js +46 -10
- package/dist/lib/logger.js +7 -3
- package/package.json +1 -1
- package/src/__tests__/elastic-transport.test.ts +94 -0
- package/src/lib/elastic-transport.ts +64 -15
- package/src/lib/logger.ts +15 -5
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
270
|
+
return splitterWithDestroy;
|
|
235
271
|
};
|
|
236
272
|
exports.createElasticTransport = createElasticTransport;
|
package/dist/lib/logger.js
CHANGED
|
@@ -219,8 +219,12 @@ function registerShutdownHandlers() {
|
|
|
219
219
|
clearTimeout(timeout);
|
|
220
220
|
resolve();
|
|
221
221
|
});
|
|
222
|
-
|
|
223
|
-
|
|
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('
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
237
|
-
|
|
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('
|
|
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.'
|