@mhmdhammoud/meritt-utils 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -94,7 +94,7 @@ describe('route and format logs', () => {
94
94
  key0: 'val0',
95
95
  key1: 'val1',
96
96
  };
97
- test('log info with structured context (single object flattened)', () => {
97
+ test('log info with structured context (single object in context field)', () => {
98
98
  //@ts-ignore
99
99
  jest.spyOn(pino_1.pino, 'destination').mockReturnValue(PINO_DESTINATION);
100
100
  //@ts-ignore
@@ -105,11 +105,10 @@ describe('route and format logs', () => {
105
105
  component: LOGGER_NAME,
106
106
  code: LOG_EVENT.code,
107
107
  msg: LOG_EVENT.msg,
108
- key0: 'val0',
109
- key1: 'val1',
108
+ context: { key0: 'val0', key1: 'val1' },
110
109
  }));
111
110
  });
112
- test('remap reserved elastic field names and reduce objects to scalars', () => {
111
+ test('remap reserved elastic field names in context', () => {
113
112
  //@ts-ignore
114
113
  jest.spyOn(pino_1.pino, 'destination').mockReturnValue(PINO_DESTINATION);
115
114
  //@ts-ignore
@@ -122,10 +121,12 @@ describe('route and format logs', () => {
122
121
  _index: 'bad-index',
123
122
  },
124
123
  });
125
- // Top-level fields use scalars only (avoids ES document_parsing_exception)
124
+ // Context holds sanitized structure; reserved names remapped recursively
126
125
  expect(PINO.info).toHaveBeenCalledWith(expect.objectContaining({
127
- mongo_id: 'abc123',
128
- nested: 'nested-1',
126
+ context: expect.objectContaining({
127
+ mongo_id: 'abc123',
128
+ nested: { mongo_id: 'nested-1', es_index: 'bad-index' },
129
+ }),
129
130
  }));
130
131
  expect(PINO.info).not.toHaveBeenCalledWith(expect.objectContaining({
131
132
  _id: expect.anything(),
@@ -33,24 +33,12 @@ function getIndexName(index, time) {
33
33
  }
34
34
  return index.replace('%{DATE}', time.substring(0, 10));
35
35
  }
36
- function initializeBulkHandler(opts, client, splitter) {
36
+ function initializeBulkHandler(opts, client, splitter, onFatalError) {
37
37
  var _a, _b, _c, _d, _e, _f, _g;
38
38
  const esVersion = Number((_b = (_a = opts.esVersion) !== null && _a !== void 0 ? _a : opts['es-version']) !== null && _b !== void 0 ? _b : 7);
39
39
  const index = (_c = opts.index) !== null && _c !== void 0 ? _c : 'pino';
40
40
  const buildIndexName = typeof index === 'function' ? index : null;
41
41
  const opType = esVersion >= 7 ? undefined : undefined;
42
- // CRITICAL FIX (issue #140): When bulk helper destroys stream after retries exhausted,
43
- // we must BOTH resurrect the pool AND reinitialize the bulk handler so logging continues.
44
- // connectionPool.resurrect exists at runtime (elastic-transport) but may not be in types
45
- const pool = client.connectionPool;
46
- const splitterWithDestroy = splitter;
47
- splitterWithDestroy.destroy = function () {
48
- if (typeof pool.resurrect === 'function') {
49
- pool.resurrect({ name: 'elasticsearch-js' });
50
- }
51
- // Reinitialize bulk handler - without this, logging stops permanently until restart
52
- initializeBulkHandler(opts, client, splitter);
53
- };
54
42
  const indexName = (time = new Date().toISOString()) => buildIndexName ? buildIndexName(time) : getIndexName(index, time);
55
43
  const bulkInsert = client.helpers.bulk({
56
44
  datasource: splitter,
@@ -77,7 +65,10 @@ function initializeBulkHandler(opts, client, splitter) {
77
65
  splitter.emit('insertError', error);
78
66
  },
79
67
  });
80
- bulkInsert.then((stats) => splitter.emit('insert', stats), (err) => splitter.emit('error', err));
68
+ bulkInsert.then((stats) => splitter.emit('insert', stats), (err) => {
69
+ splitter.emit('error', err);
70
+ onFatalError(err);
71
+ });
81
72
  }
82
73
  const createElasticTransport = (opts = {}) => {
83
74
  const splitter = split(function (line) {
@@ -128,10 +119,56 @@ const createElasticTransport = (opts = {}) => {
128
119
  clientOpts.ConnectionPool = opts.ConnectionPool;
129
120
  }
130
121
  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;
130
+ const splitterWithDestroy = splitter;
131
+ 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
+ splitterWithDestroy.destroy = function (err) {
159
+ if (err && !isTransportClosed) {
160
+ scheduleBulkHandlerRestart();
161
+ return;
162
+ }
163
+ isTransportClosed = true;
164
+ originalDestroy(err);
165
+ };
131
166
  client.diagnostic.on('resurrect', () => {
132
- initializeBulkHandler(opts, client, splitter);
167
+ if (!isBulkHandlerActive) {
168
+ scheduleBulkHandlerRestart();
169
+ }
133
170
  });
134
- initializeBulkHandler(opts, client, splitter);
171
+ startBulkHandler();
135
172
  return splitter;
136
173
  };
137
174
  exports.createElasticTransport = createElasticTransport;
@@ -77,37 +77,6 @@ const isPlainObject = (v) => v !== null &&
77
77
  !Array.isArray(v) &&
78
78
  !(v instanceof Error) &&
79
79
  !(v instanceof Date);
80
- /**
81
- * Reduces object values to scalars for top-level ES fields.
82
- * ES text/keyword fields reject nested objects; use _id or truncated JSON.
83
- */
84
- const toScalarForTopLevel = (value) => {
85
- if (value === null || value === undefined)
86
- return null;
87
- if (typeof value === 'string' ||
88
- typeof value === 'number' ||
89
- typeof value === 'boolean')
90
- return value;
91
- if (value instanceof Date)
92
- return value.toISOString();
93
- if (isObjectIdLike(value))
94
- return value.toHexString();
95
- if (value instanceof Error)
96
- return value.message;
97
- if (isPlainObject(value) && '_id' in value) {
98
- const id = value._id;
99
- if (id != null) {
100
- if (typeof id === 'string')
101
- return id;
102
- if (isObjectIdLike(id))
103
- return id.toHexString();
104
- if (typeof id === 'object' && id !== null && 'toString' in id)
105
- return String(id.toString());
106
- }
107
- }
108
- const str = typeof value === 'object' ? JSON.stringify(value) : String(value);
109
- return str.length > 200 ? `${str.slice(0, 200)}...` : str;
110
- };
111
80
  /**
112
81
  * Recursively sanitize log values for Elasticsearch safety.
113
82
  * - Remaps reserved key names (e.g. `_id` -> `mongo_id`)
@@ -412,17 +381,15 @@ class Logger {
412
381
  if (trace) {
413
382
  ecs.trace_id = trace.traceId;
414
383
  }
415
- // Structured context: flatten single plain object as top-level fields
416
- // Use scalars only for top-level ES fields; text/keyword mappings reject nested objects
384
+ // Structured context: put in single 'context' field to avoid ES mapping conflicts.
385
+ // Flattening to top-level caused document_parsing_exception (object vs scalar type mismatches).
386
+ // Nesting in context keeps structure consistent and avoids per-field mapping conflicts.
387
+ let context;
417
388
  let detail;
418
389
  if (args.length === 1 &&
419
390
  isPlainObject(args[0]) &&
420
391
  Object.keys(args[0]).length > 0) {
421
- const obj = args[0];
422
- for (const [k, v] of Object.entries(obj)) {
423
- const key = toSafeElasticFieldName(k);
424
- ecs[key] = toScalarForTopLevel(v);
425
- }
392
+ context = sanitizeForElastic(args[0]);
426
393
  }
427
394
  else {
428
395
  detail = isLocal ? args : JSON.stringify(sanitizeForElastic(args));
@@ -434,6 +401,9 @@ class Logger {
434
401
  code: event.code,
435
402
  msg: event.msg,
436
403
  };
404
+ if (context !== undefined) {
405
+ base.context = context;
406
+ }
437
407
  if (detail !== undefined) {
438
408
  base.detail = detail;
439
409
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhmdhammoud/meritt-utils",
3
- "version": "1.5.8",
3
+ "version": "1.6.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
@@ -70,7 +70,7 @@ describe('route and format logs', () => {
70
70
  key0: 'val0',
71
71
  key1: 'val1',
72
72
  }
73
- test('log info with structured context (single object flattened)', () => {
73
+ test('log info with structured context (single object in context field)', () => {
74
74
  //@ts-ignore
75
75
  jest.spyOn(pino, 'destination').mockReturnValue(PINO_DESTINATION)
76
76
  //@ts-ignore
@@ -84,13 +84,12 @@ describe('route and format logs', () => {
84
84
  component: LOGGER_NAME,
85
85
  code: LOG_EVENT.code,
86
86
  msg: LOG_EVENT.msg,
87
- key0: 'val0',
88
- key1: 'val1',
87
+ context: { key0: 'val0', key1: 'val1' },
89
88
  })
90
89
  )
91
90
  })
92
91
 
93
- test('remap reserved elastic field names and reduce objects to scalars', () => {
92
+ test('remap reserved elastic field names in context', () => {
94
93
  //@ts-ignore
95
94
  jest.spyOn(pino, 'destination').mockReturnValue(PINO_DESTINATION)
96
95
  //@ts-ignore
@@ -105,11 +104,13 @@ describe('route and format logs', () => {
105
104
  },
106
105
  })
107
106
 
108
- // Top-level fields use scalars only (avoids ES document_parsing_exception)
107
+ // Context holds sanitized structure; reserved names remapped recursively
109
108
  expect(PINO.info).toHaveBeenCalledWith(
110
109
  expect.objectContaining({
111
- mongo_id: 'abc123',
112
- nested: 'nested-1',
110
+ context: expect.objectContaining({
111
+ mongo_id: 'abc123',
112
+ nested: { mongo_id: 'nested-1', es_index: 'bad-index' },
113
+ }),
113
114
  })
114
115
  )
115
116
  expect(PINO.info).not.toHaveBeenCalledWith(
@@ -76,30 +76,14 @@ function getIndexName(
76
76
  function initializeBulkHandler(
77
77
  opts: ElasticTransportOptions,
78
78
  client: Client,
79
- splitter: NodeJS.ReadWriteStream
79
+ splitter: NodeJS.ReadWriteStream,
80
+ onFatalError: (err: Error) => void
80
81
  ): void {
81
82
  const esVersion = Number(opts.esVersion ?? opts['es-version'] ?? 7)
82
83
  const index = opts.index ?? 'pino'
83
84
  const buildIndexName = typeof index === 'function' ? index : null
84
85
  const opType = esVersion >= 7 ? undefined : undefined
85
86
 
86
- // CRITICAL FIX (issue #140): When bulk helper destroys stream after retries exhausted,
87
- // we must BOTH resurrect the pool AND reinitialize the bulk handler so logging continues.
88
- // connectionPool.resurrect exists at runtime (elastic-transport) but may not be in types
89
- const pool = client.connectionPool as {
90
- resurrect?: (opts: { name: string }) => void
91
- }
92
- const splitterWithDestroy = splitter as NodeJS.ReadWriteStream & {
93
- destroy: (err?: Error) => void
94
- }
95
- splitterWithDestroy.destroy = function () {
96
- if (typeof pool.resurrect === 'function') {
97
- pool.resurrect({ name: 'elasticsearch-js' })
98
- }
99
- // Reinitialize bulk handler - without this, logging stops permanently until restart
100
- initializeBulkHandler(opts, client, splitter)
101
- }
102
-
103
87
  const indexName = (time = new Date().toISOString()) =>
104
88
  buildIndexName ? buildIndexName(time) : getIndexName(index as string, time)
105
89
 
@@ -132,7 +116,10 @@ function initializeBulkHandler(
132
116
 
133
117
  bulkInsert.then(
134
118
  (stats) => splitter.emit('insert', stats),
135
- (err) => splitter.emit('error', err)
119
+ (err) => {
120
+ splitter.emit('error', err)
121
+ onFatalError(err)
122
+ }
136
123
  )
137
124
  }
138
125
 
@@ -193,11 +180,70 @@ export const createElasticTransport = (
193
180
 
194
181
  const client = new Client(clientOpts)
195
182
 
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
190
+
191
+ const pool = client.connectionPool as {
192
+ resurrect?: (opts: { name: string }) => void
193
+ }
194
+ const splitterWithDestroy = splitter as NodeJS.ReadWriteStream & {
195
+ destroy: (err?: Error) => void
196
+ }
197
+ 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
+ splitterWithDestroy.destroy = function (err?: Error) {
232
+ if (err && !isTransportClosed) {
233
+ scheduleBulkHandlerRestart()
234
+ return
235
+ }
236
+ isTransportClosed = true
237
+ originalDestroy(err)
238
+ }
239
+
196
240
  client.diagnostic.on('resurrect', () => {
197
- initializeBulkHandler(opts, client, splitter)
241
+ if (!isBulkHandlerActive) {
242
+ scheduleBulkHandlerRestart()
243
+ }
198
244
  })
199
245
 
200
- initializeBulkHandler(opts, client, splitter)
246
+ startBulkHandler()
201
247
 
202
248
  return splitter
203
249
  }
package/src/lib/logger.ts CHANGED
@@ -52,36 +52,6 @@ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
52
52
  !(v instanceof Error) &&
53
53
  !(v instanceof Date)
54
54
 
55
- /**
56
- * Reduces object values to scalars for top-level ES fields.
57
- * ES text/keyword fields reject nested objects; use _id or truncated JSON.
58
- */
59
- const toScalarForTopLevel = (
60
- value: unknown
61
- ): string | number | boolean | null => {
62
- if (value === null || value === undefined) return null
63
- if (
64
- typeof value === 'string' ||
65
- typeof value === 'number' ||
66
- typeof value === 'boolean'
67
- )
68
- return value
69
- if (value instanceof Date) return value.toISOString()
70
- if (isObjectIdLike(value)) return value.toHexString()
71
- if (value instanceof Error) return value.message
72
- if (isPlainObject(value) && '_id' in value) {
73
- const id = (value as { _id?: unknown })._id
74
- if (id != null) {
75
- if (typeof id === 'string') return id
76
- if (isObjectIdLike(id)) return id.toHexString()
77
- if (typeof id === 'object' && id !== null && 'toString' in id)
78
- return String((id as { toString: () => string }).toString())
79
- }
80
- }
81
- const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
82
- return str.length > 200 ? `${str.slice(0, 200)}...` : str
83
- }
84
-
85
55
  /**
86
56
  * Recursively sanitize log values for Elasticsearch safety.
87
57
  * - Remaps reserved key names (e.g. `_id` -> `mongo_id`)
@@ -491,19 +461,17 @@ class Logger {
491
461
  ecs.trace_id = trace.traceId
492
462
  }
493
463
 
494
- // Structured context: flatten single plain object as top-level fields
495
- // Use scalars only for top-level ES fields; text/keyword mappings reject nested objects
464
+ // Structured context: put in single 'context' field to avoid ES mapping conflicts.
465
+ // Flattening to top-level caused document_parsing_exception (object vs scalar type mismatches).
466
+ // Nesting in context keeps structure consistent and avoids per-field mapping conflicts.
467
+ let context: Record<string, unknown> | undefined
496
468
  let detail: unknown
497
469
  if (
498
470
  args.length === 1 &&
499
471
  isPlainObject(args[0]) &&
500
472
  Object.keys(args[0]).length > 0
501
473
  ) {
502
- const obj = args[0]
503
- for (const [k, v] of Object.entries(obj)) {
504
- const key = toSafeElasticFieldName(k)
505
- ecs[key] = toScalarForTopLevel(v)
506
- }
474
+ context = sanitizeForElastic(args[0]) as Record<string, unknown>
507
475
  } else {
508
476
  detail = isLocal ? args : JSON.stringify(sanitizeForElastic(args))
509
477
  }
@@ -515,6 +483,9 @@ class Logger {
515
483
  code: event.code,
516
484
  msg: event.msg,
517
485
  }
486
+ if (context !== undefined) {
487
+ base.context = context
488
+ }
518
489
  if (detail !== undefined) {
519
490
  base.detail = detail
520
491
  }