@mhmdhammoud/meritt-utils 1.5.5 → 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/elastic-transport.js +1 -1
- package/dist/lib/logger.js +128 -18
- package/package.json +1 -1
- package/src/__tests__/logger.test.ts +31 -0
- package/src/lib/elastic-transport.ts +1 -1
- package/src/lib/logger.ts +164 -28
- package/src/lib/trace-store.ts +4 -4
|
@@ -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
|
});
|
|
@@ -56,7 +56,7 @@ function initializeBulkHandler(opts, client, splitter) {
|
|
|
56
56
|
datasource: splitter,
|
|
57
57
|
flushBytes: (_e = (_d = opts.flushBytes) !== null && _d !== void 0 ? _d : opts['flush-bytes']) !== null && _e !== void 0 ? _e : 1000,
|
|
58
58
|
flushInterval: (_g = (_f = opts.flushInterval) !== null && _f !== void 0 ? _f : opts['flush-interval']) !== null && _g !== void 0 ? _g : 3000,
|
|
59
|
-
refreshOnCompletion:
|
|
59
|
+
refreshOnCompletion: false,
|
|
60
60
|
onDocument(doc) {
|
|
61
61
|
var _a, _b;
|
|
62
62
|
const d = doc;
|
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
|
*/
|
|
@@ -208,6 +303,13 @@ function getLogger(elasticConfig) {
|
|
|
208
303
|
esTransport.on('insertError', (err) => {
|
|
209
304
|
console.error('[Logger] Elasticsearch insert error:', err.message);
|
|
210
305
|
console.error('[Logger] Some logs failed to index to Elasticsearch.');
|
|
306
|
+
if (err.document) {
|
|
307
|
+
const docStr = JSON.stringify(err.document);
|
|
308
|
+
const preview = docStr.length > 500
|
|
309
|
+
? `${docStr.substring(0, 500)}... (truncated)`
|
|
310
|
+
: docStr;
|
|
311
|
+
console.error('[Logger] Dropped document preview:', preview);
|
|
312
|
+
}
|
|
211
313
|
});
|
|
212
314
|
// Log successful connection (for debugging)
|
|
213
315
|
esTransport.on('insert', () => {
|
|
@@ -259,35 +361,36 @@ class Logger {
|
|
|
259
361
|
buildPayload(logLevel, logEvent, args) {
|
|
260
362
|
var _a, _b;
|
|
261
363
|
const isLocal = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test';
|
|
262
|
-
//
|
|
364
|
+
// Defensive: missing Logs constant (undefined) crashes on logEvent.code
|
|
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;
|
|
369
|
+
// ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
|
|
263
370
|
const ecs = {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
message:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
},
|
|
272
|
-
host: {
|
|
273
|
-
name: (0, os_1.hostname)(),
|
|
274
|
-
},
|
|
371
|
+
log_level: logLevel,
|
|
372
|
+
log_logger: this._name,
|
|
373
|
+
event_code: event.code,
|
|
374
|
+
message: event.msg,
|
|
375
|
+
service_name: (_a = process.env.SERVER_NICKNAME) !== null && _a !== void 0 ? _a : 'unknown',
|
|
376
|
+
service_environment: (_b = process.env.NODE_ENV) !== null && _b !== void 0 ? _b : 'development',
|
|
377
|
+
host_name: (0, os_1.hostname)(),
|
|
275
378
|
};
|
|
276
379
|
// Trace context for request-scoped correlation
|
|
277
380
|
const trace = (0, trace_store_1.getTraceContext)();
|
|
278
381
|
if (trace) {
|
|
279
|
-
;
|
|
280
|
-
ecs.trace = { id: trace.traceId };
|
|
382
|
+
ecs.trace_id = trace.traceId;
|
|
281
383
|
}
|
|
282
384
|
// Structured context: flatten single plain object as top-level fields
|
|
385
|
+
// Sanitize values to avoid ES mapping conflicts (e.g. Error objects → serializable shape)
|
|
283
386
|
let detail;
|
|
284
387
|
if (args.length === 1 &&
|
|
285
388
|
isPlainObject(args[0]) &&
|
|
286
389
|
Object.keys(args[0]).length > 0) {
|
|
287
390
|
const obj = args[0];
|
|
288
391
|
for (const [k, v] of Object.entries(obj)) {
|
|
289
|
-
const key =
|
|
290
|
-
ecs[key] = v;
|
|
392
|
+
const key = toSafeElasticFieldName(k);
|
|
393
|
+
ecs[key] = sanitizeForElastic(v);
|
|
291
394
|
}
|
|
292
395
|
}
|
|
293
396
|
else {
|
|
@@ -297,12 +400,19 @@ class Logger {
|
|
|
297
400
|
const base = {
|
|
298
401
|
...ecs,
|
|
299
402
|
component: this._name,
|
|
300
|
-
code:
|
|
301
|
-
msg:
|
|
403
|
+
code: event.code,
|
|
404
|
+
msg: event.msg,
|
|
302
405
|
};
|
|
303
406
|
if (detail !== undefined) {
|
|
304
407
|
base.detail = detail;
|
|
305
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
|
+
}
|
|
306
416
|
return base;
|
|
307
417
|
}
|
|
308
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
|
})
|
|
@@ -107,7 +107,7 @@ function initializeBulkHandler(
|
|
|
107
107
|
datasource: splitter as unknown as Readable,
|
|
108
108
|
flushBytes: opts.flushBytes ?? opts['flush-bytes'] ?? 1000,
|
|
109
109
|
flushInterval: opts.flushInterval ?? opts['flush-interval'] ?? 3000,
|
|
110
|
-
refreshOnCompletion:
|
|
110
|
+
refreshOnCompletion: false,
|
|
111
111
|
onDocument(doc: unknown) {
|
|
112
112
|
const d = doc as LogDocument
|
|
113
113
|
const date = d.time ?? d['@timestamp'] ?? new Date().toISOString()
|
package/src/lib/logger.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {Logger as PinoLogger, pino, stdTimeFunctions} from 'pino'
|
|
1
|
+
import { Logger as PinoLogger, pino, stdTimeFunctions } from 'pino'
|
|
2
2
|
import * as dotenv from 'dotenv'
|
|
3
|
-
import {hostname} from 'os'
|
|
4
|
-
import {createElasticTransport} from './elastic-transport'
|
|
5
|
-
import {getTraceContext} from './trace-store'
|
|
6
|
-
import {LOG_LEVEL, LogEvent, ElasticConfig} from '../types'
|
|
3
|
+
import { hostname } from 'os'
|
|
4
|
+
import { createElasticTransport } from './elastic-transport'
|
|
5
|
+
import { getTraceContext } from './trace-store'
|
|
6
|
+
import { LOG_LEVEL, LogEvent, ElasticConfig } from '../types'
|
|
7
7
|
|
|
8
8
|
dotenv.config()
|
|
9
9
|
|
|
@@ -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
|
*/
|
|
@@ -228,9 +341,17 @@ function getLogger(elasticConfig?: ElasticConfig): PinoLogger {
|
|
|
228
341
|
})
|
|
229
342
|
|
|
230
343
|
// Handle insert errors (document indexing failures)
|
|
231
|
-
esTransport.on('insertError', (err: Error) => {
|
|
344
|
+
esTransport.on('insertError', (err: Error & { document?: unknown }) => {
|
|
232
345
|
console.error('[Logger] Elasticsearch insert error:', err.message)
|
|
233
346
|
console.error('[Logger] Some logs failed to index to Elasticsearch.')
|
|
347
|
+
if (err.document) {
|
|
348
|
+
const docStr = JSON.stringify(err.document)
|
|
349
|
+
const preview =
|
|
350
|
+
docStr.length > 500
|
|
351
|
+
? `${docStr.substring(0, 500)}... (truncated)`
|
|
352
|
+
: docStr
|
|
353
|
+
console.error('[Logger] Dropped document preview:', preview)
|
|
354
|
+
}
|
|
234
355
|
})
|
|
235
356
|
|
|
236
357
|
// Log successful connection (for debugging)
|
|
@@ -308,42 +429,50 @@ class Logger {
|
|
|
308
429
|
* - Structured: Single plain object flattened as top-level snake_case fields (Kibana filterable)
|
|
309
430
|
* - Trace: trace.id when running inside runWithTrace
|
|
310
431
|
*/
|
|
311
|
-
private buildPayload(
|
|
432
|
+
private buildPayload(
|
|
433
|
+
logLevel: LOG_LEVEL,
|
|
434
|
+
logEvent: LogEvent,
|
|
435
|
+
args: unknown[]
|
|
436
|
+
) {
|
|
312
437
|
const isLocal =
|
|
313
438
|
process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test'
|
|
314
439
|
|
|
315
|
-
//
|
|
440
|
+
// Defensive: missing Logs constant (undefined) crashes on logEvent.code
|
|
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
|
|
446
|
+
|
|
447
|
+
// ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
|
|
316
448
|
const ecs: Record<string, unknown> = {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
message:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
},
|
|
325
|
-
host: {
|
|
326
|
-
name: hostname(),
|
|
327
|
-
},
|
|
449
|
+
log_level: logLevel,
|
|
450
|
+
log_logger: this._name,
|
|
451
|
+
event_code: event.code,
|
|
452
|
+
message: event.msg,
|
|
453
|
+
service_name: process.env.SERVER_NICKNAME ?? 'unknown',
|
|
454
|
+
service_environment: process.env.NODE_ENV ?? 'development',
|
|
455
|
+
host_name: hostname(),
|
|
328
456
|
}
|
|
329
457
|
|
|
330
458
|
// Trace context for request-scoped correlation
|
|
331
459
|
const trace = getTraceContext()
|
|
332
460
|
if (trace) {
|
|
333
|
-
|
|
461
|
+
ecs.trace_id = trace.traceId
|
|
334
462
|
}
|
|
335
463
|
|
|
336
464
|
// Structured context: flatten single plain object as top-level fields
|
|
465
|
+
// Sanitize values to avoid ES mapping conflicts (e.g. Error objects → serializable shape)
|
|
337
466
|
let detail: unknown
|
|
338
467
|
if (
|
|
339
468
|
args.length === 1 &&
|
|
340
469
|
isPlainObject(args[0]) &&
|
|
341
470
|
Object.keys(args[0]).length > 0
|
|
342
471
|
) {
|
|
343
|
-
const obj = args[0]
|
|
472
|
+
const obj = args[0]
|
|
344
473
|
for (const [k, v] of Object.entries(obj)) {
|
|
345
|
-
const key =
|
|
346
|
-
|
|
474
|
+
const key = toSafeElasticFieldName(k)
|
|
475
|
+
ecs[key] = sanitizeForElastic(v)
|
|
347
476
|
}
|
|
348
477
|
} else {
|
|
349
478
|
detail = isLocal ? args : JSON.stringify(args)
|
|
@@ -353,12 +482,19 @@ class Logger {
|
|
|
353
482
|
const base: Record<string, unknown> = {
|
|
354
483
|
...ecs,
|
|
355
484
|
component: this._name,
|
|
356
|
-
code:
|
|
357
|
-
msg:
|
|
485
|
+
code: event.code,
|
|
486
|
+
msg: event.msg,
|
|
358
487
|
}
|
|
359
488
|
if (detail !== undefined) {
|
|
360
489
|
base.detail = detail
|
|
361
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
|
+
}
|
|
362
498
|
return base
|
|
363
499
|
}
|
|
364
500
|
|
|
@@ -431,7 +567,7 @@ class Logger {
|
|
|
431
567
|
const result = await fn()
|
|
432
568
|
const durationMs = Date.now() - start
|
|
433
569
|
const payload = this.buildPayload('info', logEvent, [
|
|
434
|
-
{...context, duration_ms: durationMs, success: true},
|
|
570
|
+
{ ...context, duration_ms: durationMs, success: true },
|
|
435
571
|
])
|
|
436
572
|
this._logger.info(payload)
|
|
437
573
|
return result
|
|
@@ -439,10 +575,10 @@ class Logger {
|
|
|
439
575
|
const durationMs = Date.now() - start
|
|
440
576
|
const errObj =
|
|
441
577
|
error instanceof Error
|
|
442
|
-
? {error_message: error.message, error_type: error.constructor.name}
|
|
578
|
+
? { error_message: error.message, error_type: error.constructor.name }
|
|
443
579
|
: {}
|
|
444
580
|
const payload = this.buildPayload('error', logEvent, [
|
|
445
|
-
{...context, ...errObj, duration_ms: durationMs, success: false},
|
|
581
|
+
{ ...context, ...errObj, duration_ms: durationMs, success: false },
|
|
446
582
|
])
|
|
447
583
|
this._logger.error(payload)
|
|
448
584
|
throw error
|
package/src/lib/trace-store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {AsyncLocalStorage} from 'async_hooks'
|
|
2
|
-
import {randomUUID} from 'crypto'
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Trace context for request-scoped correlation in Kibana.
|
|
@@ -27,7 +27,7 @@ export const runWithTrace = async <T>(
|
|
|
27
27
|
traceId?: string
|
|
28
28
|
): Promise<T> => {
|
|
29
29
|
const id = traceId ?? randomUUID()
|
|
30
|
-
return traceStorage.run({traceId: id}, () => fn())
|
|
30
|
+
return traceStorage.run({ traceId: id }, () => fn())
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -35,7 +35,7 @@ export const runWithTrace = async <T>(
|
|
|
35
35
|
*/
|
|
36
36
|
export const runWithTraceSync = <T>(fn: () => T, traceId?: string): T => {
|
|
37
37
|
const id = traceId ?? randomUUID()
|
|
38
|
-
return traceStorage.run({traceId: id}, () => fn())
|
|
38
|
+
return traceStorage.run({ traceId: id }, () => fn())
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|