@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.
- 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 +167 -69
- 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 +195 -88
- 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;
|
|
@@ -33,42 +34,174 @@ function getIndexName(index, time) {
|
|
|
33
34
|
}
|
|
34
35
|
return index.replace('%{DATE}', time.substring(0, 10));
|
|
35
36
|
}
|
|
36
|
-
function
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
160
|
-
scheduleBulkHandlerRestart();
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
isTransportClosed = true;
|
|
267
|
+
void bulkSender.close();
|
|
164
268
|
originalDestroy(err);
|
|
165
269
|
};
|
|
166
|
-
|
|
167
|
-
if (!isBulkHandlerActive) {
|
|
168
|
-
scheduleBulkHandlerRestart();
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
startBulkHandler();
|
|
172
|
-
return splitter;
|
|
270
|
+
return splitterWithDestroy;
|
|
173
271
|
};
|
|
174
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
|
+
})
|
|
@@ -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
|
|
83
|
+
function createBulkSender(
|
|
77
84
|
opts: ElasticTransportOptions,
|
|
78
85
|
client: Client,
|
|
79
|
-
splitter: NodeJS.ReadWriteStream
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
233
|
-
scheduleBulkHandlerRestart()
|
|
234
|
-
return
|
|
235
|
-
}
|
|
236
|
-
isTransportClosed = true
|
|
351
|
+
void bulkSender.close()
|
|
237
352
|
originalDestroy(err)
|
|
238
353
|
}
|
|
239
354
|
|
|
240
|
-
|
|
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 {
|
|
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.'
|