@mhmdhammoud/meritt-utils 1.5.6 → 1.5.7

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,28 @@ describe('route and format logs', () => {
109
109
  key1: 'val1',
110
110
  }));
111
111
  });
112
+ test('remap reserved elastic field names dynamically', () => {
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
+ expect(PINO.info).toHaveBeenCalledWith(expect.objectContaining({
126
+ mongo_id: 'abc123',
127
+ nested: expect.objectContaining({
128
+ mongo_id: 'nested-1',
129
+ es_index: 'bad-index',
130
+ }),
131
+ }));
132
+ expect(PINO.info).not.toHaveBeenCalledWith(expect.objectContaining({
133
+ _id: expect.anything(),
134
+ }));
135
+ });
112
136
  });
@@ -42,12 +42,107 @@ 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
+ * Recursively sanitize log values for Elasticsearch safety.
82
+ * - Remaps reserved key names (e.g. `_id` -> `mongo_id`)
83
+ * - Converts Error to a stable serializable shape
84
+ * - Converts ObjectId-like objects to hex strings
85
+ * - Prevents circular structure failures
86
+ */
87
+ const sanitizeForElastic = (value, seen = new WeakSet()) => {
88
+ if (value === null ||
89
+ typeof value === 'string' ||
90
+ typeof value === 'number' ||
91
+ typeof value === 'boolean') {
92
+ return value;
93
+ }
94
+ if (value instanceof Date) {
95
+ return value.toISOString();
96
+ }
97
+ if (value instanceof Error) {
98
+ return { message: value.message, type: value.constructor.name };
99
+ }
100
+ if (isObjectIdLike(value)) {
101
+ return value.toHexString();
102
+ }
103
+ if (Array.isArray(value)) {
104
+ return value.map((item) => sanitizeForElastic(item, seen));
105
+ }
106
+ if (isPlainObject(value)) {
107
+ if (seen.has(value)) {
108
+ return '[Circular]';
109
+ }
110
+ seen.add(value);
111
+ const sanitizedObject = {};
112
+ for (const [k, v] of Object.entries(value)) {
113
+ sanitizedObject[toSafeElasticFieldName(k)] = sanitizeForElastic(v, seen);
114
+ }
115
+ return sanitizedObject;
116
+ }
117
+ // Fallback for class instances and non-plain objects.
118
+ return String(value);
119
+ };
120
+ /**
121
+ * Captures the call site from the stack when log event is missing.
122
+ * Returns file:line (e.g. product.service.ts:2266) for Kibana/Elasticsearch filtering.
123
+ */
124
+ const getCallSiteForMissingLog = () => {
125
+ var _a;
126
+ try {
127
+ const stack = (_a = new Error().stack) !== null && _a !== void 0 ? _a : '';
128
+ const lines = stack.split('\n');
129
+ // First frame outside Logger / node_modules / meritt-utils
130
+ const appFrame = lines.find((line) => !line.includes('node_modules') &&
131
+ !line.includes('meritt-utils') &&
132
+ !line.includes('Logger.'));
133
+ if (!appFrame)
134
+ return undefined;
135
+ // Extract file:line e.g. "product.service.ts:2266"
136
+ const match = appFrame.match(/([^/\\]+\.(?:ts|js|tsx|jsx)):(\d+)/);
137
+ if (match) {
138
+ return `${match[1]}:${match[2]}`;
139
+ }
140
+ return appFrame.trim().slice(0, 100);
141
+ }
142
+ catch (_b) {
143
+ return undefined;
144
+ }
145
+ };
51
146
  /**
52
147
  * Pino logger backend - singleton
53
148
  */
@@ -267,9 +362,10 @@ class Logger {
267
362
  var _a, _b;
268
363
  const isLocal = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test';
269
364
  // 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' };
365
+ const useFallback = !logEvent || typeof logEvent !== 'object' || !('code' in logEvent);
366
+ const event = useFallback
367
+ ? { code: 'UNKNOWN', msg: 'Missing or invalid log event constant' }
368
+ : logEvent;
273
369
  // ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
274
370
  const ecs = {
275
371
  log_level: logLevel,
@@ -293,11 +389,8 @@ class Logger {
293
389
  Object.keys(args[0]).length > 0) {
294
390
  const obj = args[0];
295
391
  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;
392
+ const key = toSafeElasticFieldName(k);
393
+ ecs[key] = sanitizeForElastic(v);
301
394
  }
302
395
  }
303
396
  else {
@@ -313,6 +406,13 @@ class Logger {
313
406
  if (detail !== undefined) {
314
407
  base.detail = detail;
315
408
  }
409
+ // When fallback used: add call site for Kibana/Elasticsearch querying
410
+ if (useFallback) {
411
+ const callSite = getCallSiteForMissingLog();
412
+ if (callSite) {
413
+ base.missing_log_call_site = callSite;
414
+ }
415
+ }
316
416
  return base;
317
417
  }
318
418
  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.7",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
@@ -89,4 +89,35 @@ describe('route and format logs', () => {
89
89
  })
90
90
  )
91
91
  })
92
+
93
+ test('remap reserved elastic field names dynamically', () => {
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
+ expect(PINO.info).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ mongo_id: 'abc123',
111
+ nested: expect.objectContaining({
112
+ mongo_id: 'nested-1',
113
+ es_index: 'bad-index',
114
+ }),
115
+ })
116
+ )
117
+ expect(PINO.info).not.toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ _id: expect.anything(),
120
+ })
121
+ )
122
+ })
92
123
  })
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,86 @@ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
19
52
  !(v instanceof Error) &&
20
53
  !(v instanceof Date)
21
54
 
55
+ /**
56
+ * Recursively sanitize log values for Elasticsearch safety.
57
+ * - Remaps reserved key names (e.g. `_id` -> `mongo_id`)
58
+ * - Converts Error to a stable serializable shape
59
+ * - Converts ObjectId-like objects to hex strings
60
+ * - Prevents circular structure failures
61
+ */
62
+ const sanitizeForElastic = (
63
+ value: unknown,
64
+ seen: WeakSet<object> = new WeakSet<object>()
65
+ ): unknown => {
66
+ if (
67
+ value === null ||
68
+ typeof value === 'string' ||
69
+ typeof value === 'number' ||
70
+ typeof value === 'boolean'
71
+ ) {
72
+ return value
73
+ }
74
+
75
+ if (value instanceof Date) {
76
+ return value.toISOString()
77
+ }
78
+
79
+ if (value instanceof Error) {
80
+ return { message: value.message, type: value.constructor.name }
81
+ }
82
+
83
+ if (isObjectIdLike(value)) {
84
+ return value.toHexString()
85
+ }
86
+
87
+ if (Array.isArray(value)) {
88
+ return value.map((item) => sanitizeForElastic(item, seen))
89
+ }
90
+
91
+ if (isPlainObject(value)) {
92
+ if (seen.has(value)) {
93
+ return '[Circular]'
94
+ }
95
+ seen.add(value)
96
+
97
+ const sanitizedObject: Record<string, unknown> = {}
98
+ for (const [k, v] of Object.entries(value)) {
99
+ sanitizedObject[toSafeElasticFieldName(k)] = sanitizeForElastic(v, seen)
100
+ }
101
+ return sanitizedObject
102
+ }
103
+
104
+ // Fallback for class instances and non-plain objects.
105
+ return String(value)
106
+ }
107
+
108
+ /**
109
+ * Captures the call site from the stack when log event is missing.
110
+ * Returns file:line (e.g. product.service.ts:2266) for Kibana/Elasticsearch filtering.
111
+ */
112
+ const getCallSiteForMissingLog = (): string | undefined => {
113
+ try {
114
+ const stack = new Error().stack ?? ''
115
+ const lines = stack.split('\n')
116
+ // First frame outside Logger / node_modules / meritt-utils
117
+ const appFrame = lines.find(
118
+ (line) =>
119
+ !line.includes('node_modules') &&
120
+ !line.includes('meritt-utils') &&
121
+ !line.includes('Logger.')
122
+ )
123
+ if (!appFrame) return undefined
124
+ // Extract file:line e.g. "product.service.ts:2266"
125
+ const match = appFrame.match(/([^/\\]+\.(?:ts|js|tsx|jsx)):(\d+)/)
126
+ if (match) {
127
+ return `${match[1]}:${match[2]}`
128
+ }
129
+ return appFrame.trim().slice(0, 100)
130
+ } catch {
131
+ return undefined
132
+ }
133
+ }
134
+
22
135
  /**
23
136
  * Pino logger backend - singleton
24
137
  */
@@ -325,10 +438,11 @@ class Logger {
325
438
  process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test'
326
439
 
327
440
  // 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' }
441
+ const useFallback =
442
+ !logEvent || typeof logEvent !== 'object' || !('code' in logEvent)
443
+ const event = useFallback
444
+ ? { code: 'UNKNOWN', msg: 'Missing or invalid log event constant' }
445
+ : logEvent
332
446
 
333
447
  // ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
334
448
  const ecs: Record<string, unknown> = {
@@ -357,11 +471,8 @@ class Logger {
357
471
  ) {
358
472
  const obj = args[0]
359
473
  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
474
+ const key = toSafeElasticFieldName(k)
475
+ ecs[key] = sanitizeForElastic(v)
365
476
  }
366
477
  } else {
367
478
  detail = isLocal ? args : JSON.stringify(args)
@@ -377,6 +488,13 @@ class Logger {
377
488
  if (detail !== undefined) {
378
489
  base.detail = detail
379
490
  }
491
+ // When fallback used: add call site for Kibana/Elasticsearch querying
492
+ if (useFallback) {
493
+ const callSite = getCallSiteForMissingLog()
494
+ if (callSite) {
495
+ base.missing_log_call_site = callSite
496
+ }
497
+ }
380
498
  return base
381
499
  }
382
500