@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.
- package/dist/__tests__/logger.test.js +24 -0
- package/dist/lib/logger.js +108 -8
- package/package.json +1 -1
- package/src/__tests__/logger.test.ts +31 -0
- package/src/lib/logger.ts +127 -9
|
@@ -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
|
});
|
package/dist/lib/logger.js
CHANGED
|
@@ -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
|
|
271
|
-
|
|
272
|
-
|
|
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 =
|
|
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
|
@@ -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
|
|
329
|
-
logEvent
|
|
330
|
-
|
|
331
|
-
|
|
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 =
|
|
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
|
|