@mhmdhammoud/meritt-utils 1.5.4 → 1.5.6
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 +7 -5
- package/dist/lib/elastic-transport.js +1 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +5 -1
- package/dist/lib/logger.d.ts +17 -0
- package/dist/lib/logger.js +105 -8
- package/dist/lib/trace-store.d.ts +27 -0
- package/dist/lib/trace-store.js +34 -0
- package/package.json +1 -1
- package/src/__tests__/logger.test.ts +10 -6
- package/src/lib/elastic-transport.ts +1 -1
- package/src/lib/index.ts +6 -0
- package/src/lib/logger.ts +130 -10
- package/src/lib/trace-store.ts +46 -0
|
@@ -94,17 +94,19 @@ describe('route and format logs', () => {
|
|
|
94
94
|
key0: 'val0',
|
|
95
95
|
key1: 'val1',
|
|
96
96
|
};
|
|
97
|
-
test('log info with
|
|
97
|
+
test('log info with structured context (single object flattened)', () => {
|
|
98
98
|
//@ts-ignore
|
|
99
99
|
jest.spyOn(pino_1.pino, 'destination').mockReturnValue(PINO_DESTINATION);
|
|
100
100
|
//@ts-ignore
|
|
101
101
|
pino_1.pino.mockReturnValue(PINO);
|
|
102
102
|
const logger = new logger_1.default(LOGGER_NAME);
|
|
103
103
|
logger.info(LOG_EVENT, DETAILS);
|
|
104
|
-
expect(PINO.info).toHaveBeenCalledWith({
|
|
104
|
+
expect(PINO.info).toHaveBeenCalledWith(expect.objectContaining({
|
|
105
105
|
component: LOGGER_NAME,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
code: LOG_EVENT.code,
|
|
107
|
+
msg: LOG_EVENT.msg,
|
|
108
|
+
key0: 'val0',
|
|
109
|
+
key1: 'val1',
|
|
110
|
+
}));
|
|
109
111
|
});
|
|
110
112
|
});
|
|
@@ -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/index.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { default as Pdf } from './formatter';
|
|
|
4
4
|
export { default as Colorful } from './colorful';
|
|
5
5
|
export { default as ImageFull } from './imagefull';
|
|
6
6
|
export { default as Logger } from './logger';
|
|
7
|
+
export { runWithTrace, runWithTraceSync, getTraceContext, type TraceContext, } from './trace-store';
|
package/dist/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Logger = exports.ImageFull = exports.Colorful = exports.Pdf = exports.Formatter = exports.Crypto = void 0;
|
|
6
|
+
exports.getTraceContext = exports.runWithTraceSync = exports.runWithTrace = exports.Logger = exports.ImageFull = exports.Colorful = exports.Pdf = exports.Formatter = exports.Crypto = void 0;
|
|
7
7
|
var cypto_1 = require("./cypto");
|
|
8
8
|
Object.defineProperty(exports, "Crypto", { enumerable: true, get: function () { return __importDefault(cypto_1).default; } });
|
|
9
9
|
var formatter_1 = require("./formatter");
|
|
@@ -16,3 +16,7 @@ var imagefull_1 = require("./imagefull");
|
|
|
16
16
|
Object.defineProperty(exports, "ImageFull", { enumerable: true, get: function () { return __importDefault(imagefull_1).default; } });
|
|
17
17
|
var logger_1 = require("./logger");
|
|
18
18
|
Object.defineProperty(exports, "Logger", { enumerable: true, get: function () { return __importDefault(logger_1).default; } });
|
|
19
|
+
var trace_store_1 = require("./trace-store");
|
|
20
|
+
Object.defineProperty(exports, "runWithTrace", { enumerable: true, get: function () { return trace_store_1.runWithTrace; } });
|
|
21
|
+
Object.defineProperty(exports, "runWithTraceSync", { enumerable: true, get: function () { return trace_store_1.runWithTraceSync; } });
|
|
22
|
+
Object.defineProperty(exports, "getTraceContext", { enumerable: true, get: function () { return trace_store_1.getTraceContext; } });
|
package/dist/lib/logger.d.ts
CHANGED
|
@@ -23,6 +23,13 @@ declare class Logger {
|
|
|
23
23
|
* @param elasticConfig - Optional Elasticsearch configuration.
|
|
24
24
|
*/
|
|
25
25
|
constructor(name: string, elasticConfig?: ElasticConfig);
|
|
26
|
+
/**
|
|
27
|
+
* Build ECS-aligned and structured log payload.
|
|
28
|
+
* - ECS: log.level, log.logger, event.code, service.name, service.environment, message
|
|
29
|
+
* - Structured: Single plain object flattened as top-level snake_case fields (Kibana filterable)
|
|
30
|
+
* - Trace: trace.id when running inside runWithTrace
|
|
31
|
+
*/
|
|
32
|
+
private buildPayload;
|
|
26
33
|
private log;
|
|
27
34
|
/**
|
|
28
35
|
* Logs an error message.
|
|
@@ -54,5 +61,15 @@ declare class Logger {
|
|
|
54
61
|
* @param args - Additional arguments to include in the log.
|
|
55
62
|
*/
|
|
56
63
|
trace(logEvent: LogEvent, ...args: unknown[]): void;
|
|
64
|
+
/**
|
|
65
|
+
* Runs an async operation and logs its duration.
|
|
66
|
+
* Adds event.duration (ms) for Kibana performance dashboards and alerts.
|
|
67
|
+
*
|
|
68
|
+
* @param logEvent - The event to log on completion
|
|
69
|
+
* @param fn - Async function to execute
|
|
70
|
+
* @param context - Optional context object (flattened as top-level fields)
|
|
71
|
+
* @returns Result of fn
|
|
72
|
+
*/
|
|
73
|
+
withDuration<T>(logEvent: LogEvent, fn: () => Promise<T>, context?: Record<string, unknown>): Promise<T>;
|
|
57
74
|
}
|
|
58
75
|
export default Logger;
|
package/dist/lib/logger.js
CHANGED
|
@@ -36,8 +36,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.isValidLogLevel = isValidLogLevel;
|
|
37
37
|
const pino_1 = require("pino");
|
|
38
38
|
const dotenv = __importStar(require("dotenv"));
|
|
39
|
+
const os_1 = require("os");
|
|
39
40
|
const elastic_transport_1 = require("./elastic-transport");
|
|
41
|
+
const trace_store_1 = require("./trace-store");
|
|
40
42
|
dotenv.config();
|
|
43
|
+
/** Convert camelCase to snake_case for Kibana/ECS-friendly field names */
|
|
44
|
+
const toSnakeCase = (str) => str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
45
|
+
/** Check if value is a plain object (not Error, Date, Array, null) */
|
|
46
|
+
const isPlainObject = (v) => v !== null &&
|
|
47
|
+
typeof v === 'object' &&
|
|
48
|
+
!Array.isArray(v) &&
|
|
49
|
+
!(v instanceof Error) &&
|
|
50
|
+
!(v instanceof Date);
|
|
41
51
|
/**
|
|
42
52
|
* Pino logger backend - singleton
|
|
43
53
|
*/
|
|
@@ -198,6 +208,13 @@ function getLogger(elasticConfig) {
|
|
|
198
208
|
esTransport.on('insertError', (err) => {
|
|
199
209
|
console.error('[Logger] Elasticsearch insert error:', err.message);
|
|
200
210
|
console.error('[Logger] Some logs failed to index to Elasticsearch.');
|
|
211
|
+
if (err.document) {
|
|
212
|
+
const docStr = JSON.stringify(err.document);
|
|
213
|
+
const preview = docStr.length > 500
|
|
214
|
+
? `${docStr.substring(0, 500)}... (truncated)`
|
|
215
|
+
: docStr;
|
|
216
|
+
console.error('[Logger] Dropped document preview:', preview);
|
|
217
|
+
}
|
|
201
218
|
});
|
|
202
219
|
// Log successful connection (for debugging)
|
|
203
220
|
esTransport.on('insert', () => {
|
|
@@ -240,19 +257,67 @@ class Logger {
|
|
|
240
257
|
this._name = name;
|
|
241
258
|
this._logger = getLogger(elasticConfig);
|
|
242
259
|
}
|
|
243
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Build ECS-aligned and structured log payload.
|
|
262
|
+
* - ECS: log.level, log.logger, event.code, service.name, service.environment, message
|
|
263
|
+
* - Structured: Single plain object flattened as top-level snake_case fields (Kibana filterable)
|
|
264
|
+
* - Trace: trace.id when running inside runWithTrace
|
|
265
|
+
*/
|
|
266
|
+
buildPayload(logLevel, logEvent, args) {
|
|
267
|
+
var _a, _b;
|
|
268
|
+
const isLocal = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test';
|
|
269
|
+
// 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' };
|
|
273
|
+
// ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
|
|
274
|
+
const ecs = {
|
|
275
|
+
log_level: logLevel,
|
|
276
|
+
log_logger: this._name,
|
|
277
|
+
event_code: event.code,
|
|
278
|
+
message: event.msg,
|
|
279
|
+
service_name: (_a = process.env.SERVER_NICKNAME) !== null && _a !== void 0 ? _a : 'unknown',
|
|
280
|
+
service_environment: (_b = process.env.NODE_ENV) !== null && _b !== void 0 ? _b : 'development',
|
|
281
|
+
host_name: (0, os_1.hostname)(),
|
|
282
|
+
};
|
|
283
|
+
// Trace context for request-scoped correlation
|
|
284
|
+
const trace = (0, trace_store_1.getTraceContext)();
|
|
285
|
+
if (trace) {
|
|
286
|
+
ecs.trace_id = trace.traceId;
|
|
287
|
+
}
|
|
288
|
+
// Structured context: flatten single plain object as top-level fields
|
|
289
|
+
// Sanitize values to avoid ES mapping conflicts (e.g. Error objects → serializable shape)
|
|
244
290
|
let detail;
|
|
245
|
-
if (
|
|
246
|
-
|
|
291
|
+
if (args.length === 1 &&
|
|
292
|
+
isPlainObject(args[0]) &&
|
|
293
|
+
Object.keys(args[0]).length > 0) {
|
|
294
|
+
const obj = args[0];
|
|
295
|
+
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;
|
|
301
|
+
}
|
|
247
302
|
}
|
|
248
303
|
else {
|
|
249
|
-
detail = JSON.stringify(args);
|
|
304
|
+
detail = isLocal ? args : JSON.stringify(args);
|
|
250
305
|
}
|
|
251
|
-
|
|
306
|
+
// Legacy fields for backward compatibility (component, code, msg)
|
|
307
|
+
const base = {
|
|
308
|
+
...ecs,
|
|
252
309
|
component: this._name,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
310
|
+
code: event.code,
|
|
311
|
+
msg: event.msg,
|
|
312
|
+
};
|
|
313
|
+
if (detail !== undefined) {
|
|
314
|
+
base.detail = detail;
|
|
315
|
+
}
|
|
316
|
+
return base;
|
|
317
|
+
}
|
|
318
|
+
log(logLevel, logEvent, ...args) {
|
|
319
|
+
const payload = this.buildPayload(logLevel, logEvent, args);
|
|
320
|
+
this._logger[logLevel](payload);
|
|
256
321
|
}
|
|
257
322
|
/**
|
|
258
323
|
* Logs an error message.
|
|
@@ -294,5 +359,37 @@ class Logger {
|
|
|
294
359
|
trace(logEvent, ...args) {
|
|
295
360
|
this.log('trace', logEvent, ...args);
|
|
296
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Runs an async operation and logs its duration.
|
|
364
|
+
* Adds event.duration (ms) for Kibana performance dashboards and alerts.
|
|
365
|
+
*
|
|
366
|
+
* @param logEvent - The event to log on completion
|
|
367
|
+
* @param fn - Async function to execute
|
|
368
|
+
* @param context - Optional context object (flattened as top-level fields)
|
|
369
|
+
* @returns Result of fn
|
|
370
|
+
*/
|
|
371
|
+
async withDuration(logEvent, fn, context) {
|
|
372
|
+
const start = Date.now();
|
|
373
|
+
try {
|
|
374
|
+
const result = await fn();
|
|
375
|
+
const durationMs = Date.now() - start;
|
|
376
|
+
const payload = this.buildPayload('info', logEvent, [
|
|
377
|
+
{ ...context, duration_ms: durationMs, success: true },
|
|
378
|
+
]);
|
|
379
|
+
this._logger.info(payload);
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
const durationMs = Date.now() - start;
|
|
384
|
+
const errObj = error instanceof Error
|
|
385
|
+
? { error_message: error.message, error_type: error.constructor.name }
|
|
386
|
+
: {};
|
|
387
|
+
const payload = this.buildPayload('error', logEvent, [
|
|
388
|
+
{ ...context, ...errObj, duration_ms: durationMs, success: false },
|
|
389
|
+
]);
|
|
390
|
+
this._logger.error(payload);
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
297
394
|
}
|
|
298
395
|
exports.default = Logger;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace context for request-scoped correlation in Kibana.
|
|
3
|
+
* Enables filtering all logs from a single request/job by trace.id.
|
|
4
|
+
*/
|
|
5
|
+
export interface TraceContext {
|
|
6
|
+
/** Unique ID for the entire request/job flow */
|
|
7
|
+
traceId: string;
|
|
8
|
+
/** Optional span ID for sub-operations */
|
|
9
|
+
spanId?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Run an async function with a new trace context.
|
|
13
|
+
* All Logger calls within the callback will automatically include trace.id.
|
|
14
|
+
*
|
|
15
|
+
* @param fn - Async function to run within the trace context
|
|
16
|
+
* @param traceId - Optional trace ID (defaults to random UUID)
|
|
17
|
+
* @returns Result of fn
|
|
18
|
+
*/
|
|
19
|
+
export declare const runWithTrace: <T>(fn: () => Promise<T>, traceId?: string) => Promise<T>;
|
|
20
|
+
/**
|
|
21
|
+
* Run a sync function with a new trace context.
|
|
22
|
+
*/
|
|
23
|
+
export declare const runWithTraceSync: <T>(fn: () => T, traceId?: string) => T;
|
|
24
|
+
/**
|
|
25
|
+
* Get the current trace context (if running inside runWithTrace).
|
|
26
|
+
*/
|
|
27
|
+
export declare const getTraceContext: () => TraceContext | undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getTraceContext = exports.runWithTraceSync = exports.runWithTrace = void 0;
|
|
4
|
+
const async_hooks_1 = require("async_hooks");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const traceStorage = new async_hooks_1.AsyncLocalStorage();
|
|
7
|
+
/**
|
|
8
|
+
* Run an async function with a new trace context.
|
|
9
|
+
* All Logger calls within the callback will automatically include trace.id.
|
|
10
|
+
*
|
|
11
|
+
* @param fn - Async function to run within the trace context
|
|
12
|
+
* @param traceId - Optional trace ID (defaults to random UUID)
|
|
13
|
+
* @returns Result of fn
|
|
14
|
+
*/
|
|
15
|
+
const runWithTrace = async (fn, traceId) => {
|
|
16
|
+
const id = traceId !== null && traceId !== void 0 ? traceId : (0, crypto_1.randomUUID)();
|
|
17
|
+
return traceStorage.run({ traceId: id }, () => fn());
|
|
18
|
+
};
|
|
19
|
+
exports.runWithTrace = runWithTrace;
|
|
20
|
+
/**
|
|
21
|
+
* Run a sync function with a new trace context.
|
|
22
|
+
*/
|
|
23
|
+
const runWithTraceSync = (fn, traceId) => {
|
|
24
|
+
const id = traceId !== null && traceId !== void 0 ? traceId : (0, crypto_1.randomUUID)();
|
|
25
|
+
return traceStorage.run({ traceId: id }, () => fn());
|
|
26
|
+
};
|
|
27
|
+
exports.runWithTraceSync = runWithTraceSync;
|
|
28
|
+
/**
|
|
29
|
+
* Get the current trace context (if running inside runWithTrace).
|
|
30
|
+
*/
|
|
31
|
+
const getTraceContext = () => {
|
|
32
|
+
return traceStorage.getStore();
|
|
33
|
+
};
|
|
34
|
+
exports.getTraceContext = getTraceContext;
|
package/package.json
CHANGED
|
@@ -70,7 +70,7 @@ describe('route and format logs', () => {
|
|
|
70
70
|
key0: 'val0',
|
|
71
71
|
key1: 'val1',
|
|
72
72
|
}
|
|
73
|
-
test('log info with
|
|
73
|
+
test('log info with structured context (single object flattened)', () => {
|
|
74
74
|
//@ts-ignore
|
|
75
75
|
jest.spyOn(pino, 'destination').mockReturnValue(PINO_DESTINATION)
|
|
76
76
|
//@ts-ignore
|
|
@@ -79,10 +79,14 @@ describe('route and format logs', () => {
|
|
|
79
79
|
|
|
80
80
|
logger.info(LOG_EVENT, DETAILS)
|
|
81
81
|
|
|
82
|
-
expect(PINO.info).toHaveBeenCalledWith(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
expect(PINO.info).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({
|
|
84
|
+
component: LOGGER_NAME,
|
|
85
|
+
code: LOG_EVENT.code,
|
|
86
|
+
msg: LOG_EVENT.msg,
|
|
87
|
+
key0: 'val0',
|
|
88
|
+
key1: 'val1',
|
|
89
|
+
})
|
|
90
|
+
)
|
|
87
91
|
})
|
|
88
92
|
})
|
|
@@ -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/index.ts
CHANGED
|
@@ -4,3 +4,9 @@ export { default as Pdf } from './formatter'
|
|
|
4
4
|
export { default as Colorful } from './colorful'
|
|
5
5
|
export { default as ImageFull } from './imagefull'
|
|
6
6
|
export { default as Logger } from './logger'
|
|
7
|
+
export {
|
|
8
|
+
runWithTrace,
|
|
9
|
+
runWithTraceSync,
|
|
10
|
+
getTraceContext,
|
|
11
|
+
type TraceContext,
|
|
12
|
+
} from './trace-store'
|
package/src/lib/logger.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { Logger as PinoLogger, pino, stdTimeFunctions } from 'pino'
|
|
2
2
|
import * as dotenv from 'dotenv'
|
|
3
|
+
import { hostname } from 'os'
|
|
3
4
|
import { createElasticTransport } from './elastic-transport'
|
|
5
|
+
import { getTraceContext } from './trace-store'
|
|
4
6
|
import { LOG_LEVEL, LogEvent, ElasticConfig } from '../types'
|
|
5
7
|
|
|
6
8
|
dotenv.config()
|
|
7
9
|
|
|
10
|
+
/** Convert camelCase to snake_case for Kibana/ECS-friendly field names */
|
|
11
|
+
const toSnakeCase = (str: string): string =>
|
|
12
|
+
str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)
|
|
13
|
+
|
|
14
|
+
/** Check if value is a plain object (not Error, Date, Array, null) */
|
|
15
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
16
|
+
v !== null &&
|
|
17
|
+
typeof v === 'object' &&
|
|
18
|
+
!Array.isArray(v) &&
|
|
19
|
+
!(v instanceof Error) &&
|
|
20
|
+
!(v instanceof Date)
|
|
21
|
+
|
|
8
22
|
/**
|
|
9
23
|
* Pino logger backend - singleton
|
|
10
24
|
*/
|
|
@@ -214,9 +228,17 @@ function getLogger(elasticConfig?: ElasticConfig): PinoLogger {
|
|
|
214
228
|
})
|
|
215
229
|
|
|
216
230
|
// Handle insert errors (document indexing failures)
|
|
217
|
-
esTransport.on('insertError', (err: Error) => {
|
|
231
|
+
esTransport.on('insertError', (err: Error & { document?: unknown }) => {
|
|
218
232
|
console.error('[Logger] Elasticsearch insert error:', err.message)
|
|
219
233
|
console.error('[Logger] Some logs failed to index to Elasticsearch.')
|
|
234
|
+
if (err.document) {
|
|
235
|
+
const docStr = JSON.stringify(err.document)
|
|
236
|
+
const preview =
|
|
237
|
+
docStr.length > 500
|
|
238
|
+
? `${docStr.substring(0, 500)}... (truncated)`
|
|
239
|
+
: docStr
|
|
240
|
+
console.error('[Logger] Dropped document preview:', preview)
|
|
241
|
+
}
|
|
220
242
|
})
|
|
221
243
|
|
|
222
244
|
// Log successful connection (for debugging)
|
|
@@ -288,18 +310,79 @@ class Logger {
|
|
|
288
310
|
this._logger = getLogger(elasticConfig)
|
|
289
311
|
}
|
|
290
312
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Build ECS-aligned and structured log payload.
|
|
315
|
+
* - ECS: log.level, log.logger, event.code, service.name, service.environment, message
|
|
316
|
+
* - Structured: Single plain object flattened as top-level snake_case fields (Kibana filterable)
|
|
317
|
+
* - Trace: trace.id when running inside runWithTrace
|
|
318
|
+
*/
|
|
319
|
+
private buildPayload(
|
|
320
|
+
logLevel: LOG_LEVEL,
|
|
321
|
+
logEvent: LogEvent,
|
|
322
|
+
args: unknown[]
|
|
323
|
+
) {
|
|
324
|
+
const isLocal =
|
|
325
|
+
process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test'
|
|
326
|
+
|
|
327
|
+
// 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' }
|
|
332
|
+
|
|
333
|
+
// ECS-aligned fields for Kibana (flat names to avoid mapping conflicts with existing indices)
|
|
334
|
+
const ecs: Record<string, unknown> = {
|
|
335
|
+
log_level: logLevel,
|
|
336
|
+
log_logger: this._name,
|
|
337
|
+
event_code: event.code,
|
|
338
|
+
message: event.msg,
|
|
339
|
+
service_name: process.env.SERVER_NICKNAME ?? 'unknown',
|
|
340
|
+
service_environment: process.env.NODE_ENV ?? 'development',
|
|
341
|
+
host_name: hostname(),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Trace context for request-scoped correlation
|
|
345
|
+
const trace = getTraceContext()
|
|
346
|
+
if (trace) {
|
|
347
|
+
ecs.trace_id = trace.traceId
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Structured context: flatten single plain object as top-level fields
|
|
351
|
+
// Sanitize values to avoid ES mapping conflicts (e.g. Error objects → serializable shape)
|
|
352
|
+
let detail: unknown
|
|
353
|
+
if (
|
|
354
|
+
args.length === 1 &&
|
|
355
|
+
isPlainObject(args[0]) &&
|
|
356
|
+
Object.keys(args[0]).length > 0
|
|
357
|
+
) {
|
|
358
|
+
const obj = args[0]
|
|
359
|
+
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
|
|
365
|
+
}
|
|
295
366
|
} else {
|
|
296
|
-
detail = JSON.stringify(args)
|
|
367
|
+
detail = isLocal ? args : JSON.stringify(args)
|
|
297
368
|
}
|
|
298
|
-
|
|
369
|
+
|
|
370
|
+
// Legacy fields for backward compatibility (component, code, msg)
|
|
371
|
+
const base: Record<string, unknown> = {
|
|
372
|
+
...ecs,
|
|
299
373
|
component: this._name,
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
374
|
+
code: event.code,
|
|
375
|
+
msg: event.msg,
|
|
376
|
+
}
|
|
377
|
+
if (detail !== undefined) {
|
|
378
|
+
base.detail = detail
|
|
379
|
+
}
|
|
380
|
+
return base
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private log(logLevel: LOG_LEVEL, logEvent: LogEvent, ...args: unknown[]) {
|
|
384
|
+
const payload = this.buildPayload(logLevel, logEvent, args)
|
|
385
|
+
this._logger[logLevel](payload)
|
|
303
386
|
}
|
|
304
387
|
|
|
305
388
|
/**
|
|
@@ -346,6 +429,43 @@ class Logger {
|
|
|
346
429
|
trace(logEvent: LogEvent, ...args: unknown[]) {
|
|
347
430
|
this.log('trace', logEvent, ...args)
|
|
348
431
|
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Runs an async operation and logs its duration.
|
|
435
|
+
* Adds event.duration (ms) for Kibana performance dashboards and alerts.
|
|
436
|
+
*
|
|
437
|
+
* @param logEvent - The event to log on completion
|
|
438
|
+
* @param fn - Async function to execute
|
|
439
|
+
* @param context - Optional context object (flattened as top-level fields)
|
|
440
|
+
* @returns Result of fn
|
|
441
|
+
*/
|
|
442
|
+
async withDuration<T>(
|
|
443
|
+
logEvent: LogEvent,
|
|
444
|
+
fn: () => Promise<T>,
|
|
445
|
+
context?: Record<string, unknown>
|
|
446
|
+
): Promise<T> {
|
|
447
|
+
const start = Date.now()
|
|
448
|
+
try {
|
|
449
|
+
const result = await fn()
|
|
450
|
+
const durationMs = Date.now() - start
|
|
451
|
+
const payload = this.buildPayload('info', logEvent, [
|
|
452
|
+
{ ...context, duration_ms: durationMs, success: true },
|
|
453
|
+
])
|
|
454
|
+
this._logger.info(payload)
|
|
455
|
+
return result
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const durationMs = Date.now() - start
|
|
458
|
+
const errObj =
|
|
459
|
+
error instanceof Error
|
|
460
|
+
? { error_message: error.message, error_type: error.constructor.name }
|
|
461
|
+
: {}
|
|
462
|
+
const payload = this.buildPayload('error', logEvent, [
|
|
463
|
+
{ ...context, ...errObj, duration_ms: durationMs, success: false },
|
|
464
|
+
])
|
|
465
|
+
this._logger.error(payload)
|
|
466
|
+
throw error
|
|
467
|
+
}
|
|
468
|
+
}
|
|
349
469
|
}
|
|
350
470
|
|
|
351
471
|
export default Logger
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Trace context for request-scoped correlation in Kibana.
|
|
6
|
+
* Enables filtering all logs from a single request/job by trace.id.
|
|
7
|
+
*/
|
|
8
|
+
export interface TraceContext {
|
|
9
|
+
/** Unique ID for the entire request/job flow */
|
|
10
|
+
traceId: string
|
|
11
|
+
/** Optional span ID for sub-operations */
|
|
12
|
+
spanId?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const traceStorage = new AsyncLocalStorage<TraceContext>()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run an async function with a new trace context.
|
|
19
|
+
* All Logger calls within the callback will automatically include trace.id.
|
|
20
|
+
*
|
|
21
|
+
* @param fn - Async function to run within the trace context
|
|
22
|
+
* @param traceId - Optional trace ID (defaults to random UUID)
|
|
23
|
+
* @returns Result of fn
|
|
24
|
+
*/
|
|
25
|
+
export const runWithTrace = async <T>(
|
|
26
|
+
fn: () => Promise<T>,
|
|
27
|
+
traceId?: string
|
|
28
|
+
): Promise<T> => {
|
|
29
|
+
const id = traceId ?? randomUUID()
|
|
30
|
+
return traceStorage.run({ traceId: id }, () => fn())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run a sync function with a new trace context.
|
|
35
|
+
*/
|
|
36
|
+
export const runWithTraceSync = <T>(fn: () => T, traceId?: string): T => {
|
|
37
|
+
const id = traceId ?? randomUUID()
|
|
38
|
+
return traceStorage.run({ traceId: id }, () => fn())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the current trace context (if running inside runWithTrace).
|
|
43
|
+
*/
|
|
44
|
+
export const getTraceContext = (): TraceContext | undefined => {
|
|
45
|
+
return traceStorage.getStore()
|
|
46
|
+
}
|