@mhmdhammoud/meritt-utils 1.5.6 → 1.5.8

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.
@@ -109,4 +109,26 @@ describe('route and format logs', () => {
109
109
  key1: 'val1',
110
110
  }));
111
111
  });
112
+ test('remap reserved elastic field names and reduce objects to scalars', () => {
113
+ //@ts-ignore
114
+ jest.spyOn(pino_1.pino, 'destination').mockReturnValue(PINO_DESTINATION);
115
+ //@ts-ignore
116
+ pino_1.pino.mockReturnValue(PINO);
117
+ const logger = new logger_1.default(LOGGER_NAME);
118
+ logger.info(LOG_EVENT, {
119
+ _id: 'abc123',
120
+ nested: {
121
+ _id: 'nested-1',
122
+ _index: 'bad-index',
123
+ },
124
+ });
125
+ // Top-level fields use scalars only (avoids ES document_parsing_exception)
126
+ expect(PINO.info).toHaveBeenCalledWith(expect.objectContaining({
127
+ mongo_id: 'abc123',
128
+ nested: 'nested-1',
129
+ }));
130
+ expect(PINO.info).not.toHaveBeenCalledWith(expect.objectContaining({
131
+ _id: expect.anything(),
132
+ }));
133
+ });
112
134
  });
@@ -42,12 +42,138 @@ const trace_store_1 = require("./trace-store");
42
42
  dotenv.config();
43
43
  /** Convert camelCase to snake_case for Kibana/ECS-friendly field names */
44
44
  const toSnakeCase = (str) => str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
45
+ /**
46
+ * Elasticsearch reserves metadata fields (e.g. `_id`) and rejects documents
47
+ * containing them in payload body. Remap them to safe application fields.
48
+ */
49
+ const RESERVED_ES_FIELD_ALIASES = {
50
+ _id: 'mongo_id',
51
+ _index: 'es_index',
52
+ _type: 'es_type',
53
+ _score: 'es_score',
54
+ _source: 'es_source',
55
+ _routing: 'es_routing',
56
+ _seq_no: 'es_seq_no',
57
+ _primary_term: 'es_primary_term',
58
+ _version: 'es_version',
59
+ };
60
+ /** Convert arbitrary field names into ES-safe flattened keys */
61
+ const toSafeElasticFieldName = (key) => {
62
+ const normalized = toSnakeCase(key);
63
+ const mapped = RESERVED_ES_FIELD_ALIASES[normalized];
64
+ if (mapped) {
65
+ return mapped;
66
+ }
67
+ // Any leading underscore can conflict with ES internals, remap defensively.
68
+ return normalized.startsWith('_') ? `meta${normalized}` : normalized;
69
+ };
70
+ const isObjectIdLike = (v) => v !== null &&
71
+ typeof v === 'object' &&
72
+ 'toHexString' in v &&
73
+ typeof v.toHexString === 'function';
45
74
  /** Check if value is a plain object (not Error, Date, Array, null) */
46
75
  const isPlainObject = (v) => v !== null &&
47
76
  typeof v === 'object' &&
48
77
  !Array.isArray(v) &&
49
78
  !(v instanceof Error) &&
50
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
+ /**
112
+ * Recursively sanitize log values for Elasticsearch safety.
113
+ * - Remaps reserved key names (e.g. `_id` -> `mongo_id`)
114
+ * - Converts Error to a stable serializable shape
115
+ * - Converts ObjectId-like objects to hex strings
116
+ * - Prevents circular structure failures
117
+ */
118
+ const sanitizeForElastic = (value, seen = new WeakSet()) => {
119
+ if (value === null ||
120
+ typeof value === 'string' ||
121
+ typeof value === 'number' ||
122
+ typeof value === 'boolean') {
123
+ return value;
124
+ }
125
+ if (value instanceof Date) {
126
+ return value.toISOString();
127
+ }
128
+ if (value instanceof Error) {
129
+ return { message: value.message, type: value.constructor.name };
130
+ }
131
+ if (isObjectIdLike(value)) {
132
+ return value.toHexString();
133
+ }
134
+ if (Array.isArray(value)) {
135
+ return value.map((item) => sanitizeForElastic(item, seen));
136
+ }
137
+ if (isPlainObject(value)) {
138
+ if (seen.has(value)) {
139
+ return '[Circular]';
140
+ }
141
+ seen.add(value);
142
+ const sanitizedObject = {};
143
+ for (const [k, v] of Object.entries(value)) {
144
+ sanitizedObject[toSafeElasticFieldName(k)] = sanitizeForElastic(v, seen);
145
+ }
146
+ return sanitizedObject;
147
+ }
148
+ // Fallback for class instances and non-plain objects.
149
+ return String(value);
150
+ };
151
+ /**
152
+ * Captures the call site from the stack when log event is missing.
153
+ * Returns file:line (e.g. product.service.ts:2266) for Kibana/Elasticsearch filtering.
154
+ */
155
+ const getCallSiteForMissingLog = () => {
156
+ var _a;
157
+ try {
158
+ const stack = (_a = new Error().stack) !== null && _a !== void 0 ? _a : '';
159
+ const lines = stack.split('\n');
160
+ // First frame outside Logger / node_modules / meritt-utils
161
+ const appFrame = lines.find((line) => !line.includes('node_modules') &&
162
+ !line.includes('meritt-utils') &&
163
+ !line.includes('Logger.'));
164
+ if (!appFrame)
165
+ return undefined;
166
+ // Extract file:line e.g. "product.service.ts:2266"
167
+ const match = appFrame.match(/([^/\\]+\.(?:ts|js|tsx|jsx)):(\d+)/);
168
+ if (match) {
169
+ return `${match[1]}:${match[2]}`;
170
+ }
171
+ return appFrame.trim().slice(0, 100);
172
+ }
173
+ catch (_b) {
174
+ return undefined;
175
+ }
176
+ };
51
177
  /**
52
178
  * Pino logger backend - singleton
53
179
  */
@@ -267,9 +393,10 @@ class Logger {
267
393
  var _a, _b;
268
394
  const isLocal = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test';
269
395
  // Defensive: missing Logs constant (undefined) crashes on logEvent.code
270
- const event = logEvent && typeof logEvent === 'object' && 'code' in logEvent
271
- ? logEvent
272
- : { code: 'UNKNOWN', msg: 'Missing or invalid log event constant' };
396
+ const useFallback = !logEvent || typeof logEvent !== 'object' || !('code' in logEvent);
397
+ const event = useFallback
398
+ ? { code: 'UNKNOWN', msg: 'Missing or invalid log event constant' }
399
+ : logEvent;
273
400
  // ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
274
401
  const ecs = {
275
402
  log_level: logLevel,
@@ -286,22 +413,19 @@ class Logger {
286
413
  ecs.trace_id = trace.traceId;
287
414
  }
288
415
  // Structured context: flatten single plain object as top-level fields
289
- // Sanitize values to avoid ES mapping conflicts (e.g. Error objects → serializable shape)
416
+ // Use scalars only for top-level ES fields; text/keyword mappings reject nested objects
290
417
  let detail;
291
418
  if (args.length === 1 &&
292
419
  isPlainObject(args[0]) &&
293
420
  Object.keys(args[0]).length > 0) {
294
421
  const obj = args[0];
295
422
  for (const [k, v] of Object.entries(obj)) {
296
- const key = toSnakeCase(k);
297
- ecs[key] =
298
- v instanceof Error
299
- ? { message: v.message, type: v.constructor.name }
300
- : v;
423
+ const key = toSafeElasticFieldName(k);
424
+ ecs[key] = toScalarForTopLevel(v);
301
425
  }
302
426
  }
303
427
  else {
304
- detail = isLocal ? args : JSON.stringify(args);
428
+ detail = isLocal ? args : JSON.stringify(sanitizeForElastic(args));
305
429
  }
306
430
  // Legacy fields for backward compatibility (component, code, msg)
307
431
  const base = {
@@ -313,6 +437,13 @@ class Logger {
313
437
  if (detail !== undefined) {
314
438
  base.detail = detail;
315
439
  }
440
+ // When fallback used: add call site for Kibana/Elasticsearch querying
441
+ if (useFallback) {
442
+ const callSite = getCallSiteForMissingLog();
443
+ if (callSite) {
444
+ base.missing_log_call_site = callSite;
445
+ }
446
+ }
316
447
  return base;
317
448
  }
318
449
  log(logLevel, logEvent, ...args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhmdhammoud/meritt-utils",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
@@ -89,4 +89,33 @@ describe('route and format logs', () => {
89
89
  })
90
90
  )
91
91
  })
92
+
93
+ test('remap reserved elastic field names and reduce objects to scalars', () => {
94
+ //@ts-ignore
95
+ jest.spyOn(pino, 'destination').mockReturnValue(PINO_DESTINATION)
96
+ //@ts-ignore
97
+ pino.mockReturnValue(PINO)
98
+ const logger = new Logger(LOGGER_NAME)
99
+
100
+ logger.info(LOG_EVENT, {
101
+ _id: 'abc123',
102
+ nested: {
103
+ _id: 'nested-1',
104
+ _index: 'bad-index',
105
+ },
106
+ })
107
+
108
+ // Top-level fields use scalars only (avoids ES document_parsing_exception)
109
+ expect(PINO.info).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ mongo_id: 'abc123',
112
+ nested: 'nested-1',
113
+ })
114
+ )
115
+ expect(PINO.info).not.toHaveBeenCalledWith(
116
+ expect.objectContaining({
117
+ _id: expect.anything(),
118
+ })
119
+ )
120
+ })
92
121
  })
package/src/lib/logger.ts CHANGED
@@ -11,6 +11,39 @@ dotenv.config()
11
11
  const toSnakeCase = (str: string): string =>
12
12
  str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)
13
13
 
14
+ /**
15
+ * Elasticsearch reserves metadata fields (e.g. `_id`) and rejects documents
16
+ * containing them in payload body. Remap them to safe application fields.
17
+ */
18
+ const RESERVED_ES_FIELD_ALIASES: Record<string, string> = {
19
+ _id: 'mongo_id',
20
+ _index: 'es_index',
21
+ _type: 'es_type',
22
+ _score: 'es_score',
23
+ _source: 'es_source',
24
+ _routing: 'es_routing',
25
+ _seq_no: 'es_seq_no',
26
+ _primary_term: 'es_primary_term',
27
+ _version: 'es_version',
28
+ }
29
+
30
+ /** Convert arbitrary field names into ES-safe flattened keys */
31
+ const toSafeElasticFieldName = (key: string): string => {
32
+ const normalized = toSnakeCase(key)
33
+ const mapped = RESERVED_ES_FIELD_ALIASES[normalized]
34
+ if (mapped) {
35
+ return mapped
36
+ }
37
+ // Any leading underscore can conflict with ES internals, remap defensively.
38
+ return normalized.startsWith('_') ? `meta${normalized}` : normalized
39
+ }
40
+
41
+ const isObjectIdLike = (v: unknown): v is { toHexString: () => string } =>
42
+ v !== null &&
43
+ typeof v === 'object' &&
44
+ 'toHexString' in v &&
45
+ typeof (v as { toHexString?: unknown }).toHexString === 'function'
46
+
14
47
  /** Check if value is a plain object (not Error, Date, Array, null) */
15
48
  const isPlainObject = (v: unknown): v is Record<string, unknown> =>
16
49
  v !== null &&
@@ -19,6 +52,116 @@ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
19
52
  !(v instanceof Error) &&
20
53
  !(v instanceof Date)
21
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
+ /**
86
+ * Recursively sanitize log values for Elasticsearch safety.
87
+ * - Remaps reserved key names (e.g. `_id` -> `mongo_id`)
88
+ * - Converts Error to a stable serializable shape
89
+ * - Converts ObjectId-like objects to hex strings
90
+ * - Prevents circular structure failures
91
+ */
92
+ const sanitizeForElastic = (
93
+ value: unknown,
94
+ seen: WeakSet<object> = new WeakSet<object>()
95
+ ): unknown => {
96
+ if (
97
+ value === null ||
98
+ typeof value === 'string' ||
99
+ typeof value === 'number' ||
100
+ typeof value === 'boolean'
101
+ ) {
102
+ return value
103
+ }
104
+
105
+ if (value instanceof Date) {
106
+ return value.toISOString()
107
+ }
108
+
109
+ if (value instanceof Error) {
110
+ return { message: value.message, type: value.constructor.name }
111
+ }
112
+
113
+ if (isObjectIdLike(value)) {
114
+ return value.toHexString()
115
+ }
116
+
117
+ if (Array.isArray(value)) {
118
+ return value.map((item) => sanitizeForElastic(item, seen))
119
+ }
120
+
121
+ if (isPlainObject(value)) {
122
+ if (seen.has(value)) {
123
+ return '[Circular]'
124
+ }
125
+ seen.add(value)
126
+
127
+ const sanitizedObject: Record<string, unknown> = {}
128
+ for (const [k, v] of Object.entries(value)) {
129
+ sanitizedObject[toSafeElasticFieldName(k)] = sanitizeForElastic(v, seen)
130
+ }
131
+ return sanitizedObject
132
+ }
133
+
134
+ // Fallback for class instances and non-plain objects.
135
+ return String(value)
136
+ }
137
+
138
+ /**
139
+ * Captures the call site from the stack when log event is missing.
140
+ * Returns file:line (e.g. product.service.ts:2266) for Kibana/Elasticsearch filtering.
141
+ */
142
+ const getCallSiteForMissingLog = (): string | undefined => {
143
+ try {
144
+ const stack = new Error().stack ?? ''
145
+ const lines = stack.split('\n')
146
+ // First frame outside Logger / node_modules / meritt-utils
147
+ const appFrame = lines.find(
148
+ (line) =>
149
+ !line.includes('node_modules') &&
150
+ !line.includes('meritt-utils') &&
151
+ !line.includes('Logger.')
152
+ )
153
+ if (!appFrame) return undefined
154
+ // Extract file:line e.g. "product.service.ts:2266"
155
+ const match = appFrame.match(/([^/\\]+\.(?:ts|js|tsx|jsx)):(\d+)/)
156
+ if (match) {
157
+ return `${match[1]}:${match[2]}`
158
+ }
159
+ return appFrame.trim().slice(0, 100)
160
+ } catch {
161
+ return undefined
162
+ }
163
+ }
164
+
22
165
  /**
23
166
  * Pino logger backend - singleton
24
167
  */
@@ -325,10 +468,11 @@ class Logger {
325
468
  process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test'
326
469
 
327
470
  // Defensive: missing Logs constant (undefined) crashes on logEvent.code
328
- const event =
329
- logEvent && typeof logEvent === 'object' && 'code' in logEvent
330
- ? logEvent
331
- : { code: 'UNKNOWN', msg: 'Missing or invalid log event constant' }
471
+ const useFallback =
472
+ !logEvent || typeof logEvent !== 'object' || !('code' in logEvent)
473
+ const event = useFallback
474
+ ? { code: 'UNKNOWN', msg: 'Missing or invalid log event constant' }
475
+ : logEvent
332
476
 
333
477
  // ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
334
478
  const ecs: Record<string, unknown> = {
@@ -348,7 +492,7 @@ class Logger {
348
492
  }
349
493
 
350
494
  // Structured context: flatten single plain object as top-level fields
351
- // Sanitize values to avoid ES mapping conflicts (e.g. Error objects → serializable shape)
495
+ // Use scalars only for top-level ES fields; text/keyword mappings reject nested objects
352
496
  let detail: unknown
353
497
  if (
354
498
  args.length === 1 &&
@@ -357,14 +501,11 @@ class Logger {
357
501
  ) {
358
502
  const obj = args[0]
359
503
  for (const [k, v] of Object.entries(obj)) {
360
- const key = toSnakeCase(k)
361
- ecs[key] =
362
- v instanceof Error
363
- ? { message: v.message, type: v.constructor.name }
364
- : v
504
+ const key = toSafeElasticFieldName(k)
505
+ ecs[key] = toScalarForTopLevel(v)
365
506
  }
366
507
  } else {
367
- detail = isLocal ? args : JSON.stringify(args)
508
+ detail = isLocal ? args : JSON.stringify(sanitizeForElastic(args))
368
509
  }
369
510
 
370
511
  // Legacy fields for backward compatibility (component, code, msg)
@@ -377,6 +518,13 @@ class Logger {
377
518
  if (detail !== undefined) {
378
519
  base.detail = detail
379
520
  }
521
+ // When fallback used: add call site for Kibana/Elasticsearch querying
522
+ if (useFallback) {
523
+ const callSite = getCallSiteForMissingLog()
524
+ if (callSite) {
525
+ base.missing_log_call_site = callSite
526
+ }
527
+ }
380
528
  return base
381
529
  }
382
530