@mhmdhammoud/meritt-utils 1.5.3 → 1.5.5

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.
@@ -94,17 +94,19 @@ describe('route and format logs', () => {
94
94
  key0: 'val0',
95
95
  key1: 'val1',
96
96
  };
97
- test('log info with details', () => {
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
- ...LOG_EVENT,
107
- detail: [DETAILS],
108
- });
106
+ code: LOG_EVENT.code,
107
+ msg: LOG_EVENT.msg,
108
+ key0: 'val0',
109
+ key1: 'val1',
110
+ }));
109
111
  });
110
112
  });
@@ -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; } });
@@ -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;
@@ -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
  */
@@ -240,19 +250,64 @@ class Logger {
240
250
  this._name = name;
241
251
  this._logger = getLogger(elasticConfig);
242
252
  }
243
- log(logLevel, logEvent, ...args) {
253
+ /**
254
+ * Build ECS-aligned and structured log payload.
255
+ * - ECS: log.level, log.logger, event.code, service.name, service.environment, message
256
+ * - Structured: Single plain object flattened as top-level snake_case fields (Kibana filterable)
257
+ * - Trace: trace.id when running inside runWithTrace
258
+ */
259
+ buildPayload(logLevel, logEvent, args) {
260
+ var _a, _b;
261
+ const isLocal = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test';
262
+ // ECS-aligned fields for Kibana Discover, Lens, alerts
263
+ const ecs = {
264
+ 'log.level': logLevel,
265
+ 'log.logger': this._name,
266
+ 'event.code': logEvent.code,
267
+ message: logEvent.msg,
268
+ service: {
269
+ name: (_a = process.env.SERVER_NICKNAME) !== null && _a !== void 0 ? _a : 'unknown',
270
+ environment: (_b = process.env.NODE_ENV) !== null && _b !== void 0 ? _b : 'development',
271
+ },
272
+ host: {
273
+ name: (0, os_1.hostname)(),
274
+ },
275
+ };
276
+ // Trace context for request-scoped correlation
277
+ const trace = (0, trace_store_1.getTraceContext)();
278
+ if (trace) {
279
+ ;
280
+ ecs.trace = { id: trace.traceId };
281
+ }
282
+ // Structured context: flatten single plain object as top-level fields
244
283
  let detail;
245
- if (process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test') {
246
- detail = args;
284
+ if (args.length === 1 &&
285
+ isPlainObject(args[0]) &&
286
+ Object.keys(args[0]).length > 0) {
287
+ const obj = args[0];
288
+ for (const [k, v] of Object.entries(obj)) {
289
+ const key = toSnakeCase(k);
290
+ ecs[key] = v;
291
+ }
247
292
  }
248
293
  else {
249
- detail = JSON.stringify(args);
294
+ detail = isLocal ? args : JSON.stringify(args);
250
295
  }
251
- this._logger[logLevel]({
296
+ // Legacy fields for backward compatibility (component, code, msg)
297
+ const base = {
298
+ ...ecs,
252
299
  component: this._name,
253
- ...logEvent,
254
- detail,
255
- });
300
+ code: logEvent.code,
301
+ msg: logEvent.msg,
302
+ };
303
+ if (detail !== undefined) {
304
+ base.detail = detail;
305
+ }
306
+ return base;
307
+ }
308
+ log(logLevel, logEvent, ...args) {
309
+ const payload = this.buildPayload(logLevel, logEvent, args);
310
+ this._logger[logLevel](payload);
256
311
  }
257
312
  /**
258
313
  * Logs an error message.
@@ -294,5 +349,37 @@ class Logger {
294
349
  trace(logEvent, ...args) {
295
350
  this.log('trace', logEvent, ...args);
296
351
  }
352
+ /**
353
+ * Runs an async operation and logs its duration.
354
+ * Adds event.duration (ms) for Kibana performance dashboards and alerts.
355
+ *
356
+ * @param logEvent - The event to log on completion
357
+ * @param fn - Async function to execute
358
+ * @param context - Optional context object (flattened as top-level fields)
359
+ * @returns Result of fn
360
+ */
361
+ async withDuration(logEvent, fn, context) {
362
+ const start = Date.now();
363
+ try {
364
+ const result = await fn();
365
+ const durationMs = Date.now() - start;
366
+ const payload = this.buildPayload('info', logEvent, [
367
+ { ...context, duration_ms: durationMs, success: true },
368
+ ]);
369
+ this._logger.info(payload);
370
+ return result;
371
+ }
372
+ catch (error) {
373
+ const durationMs = Date.now() - start;
374
+ const errObj = error instanceof Error
375
+ ? { error_message: error.message, error_type: error.constructor.name }
376
+ : {};
377
+ const payload = this.buildPayload('error', logEvent, [
378
+ { ...context, ...errObj, duration_ms: durationMs, success: false },
379
+ ]);
380
+ this._logger.error(payload);
381
+ throw error;
382
+ }
383
+ }
297
384
  }
298
385
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhmdhammoud/meritt-utils",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
@@ -44,7 +44,7 @@
44
44
  "author": "Mhmdhammoud",
45
45
  "license": "ISC",
46
46
  "dependencies": {
47
- "@elastic/elasticsearch": "^9.3.2",
47
+ "@elastic/elasticsearch": "^8.17.0",
48
48
  "axios": "^1.4.0",
49
49
  "dotenv": "^16.4.1",
50
50
  "imagesloaded": "^5.0.0",
@@ -70,7 +70,7 @@ describe('route and format logs', () => {
70
70
  key0: 'val0',
71
71
  key1: 'val1',
72
72
  }
73
- test('log info with details', () => {
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
- component: LOGGER_NAME,
84
- ...LOG_EVENT,
85
- detail: [DETAILS],
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
  })
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
- 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 { createElasticTransport } from './elastic-transport'
4
- 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'
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
  */
@@ -288,18 +302,69 @@ class Logger {
288
302
  this._logger = getLogger(elasticConfig)
289
303
  }
290
304
 
291
- private log(logLevel: LOG_LEVEL, logEvent: LogEvent, ...args: unknown[]) {
292
- let detail
293
- if (process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test') {
294
- detail = args
305
+ /**
306
+ * Build ECS-aligned and structured log payload.
307
+ * - ECS: log.level, log.logger, event.code, service.name, service.environment, message
308
+ * - Structured: Single plain object flattened as top-level snake_case fields (Kibana filterable)
309
+ * - Trace: trace.id when running inside runWithTrace
310
+ */
311
+ private buildPayload(logLevel: LOG_LEVEL, logEvent: LogEvent, args: unknown[]) {
312
+ const isLocal =
313
+ process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test'
314
+
315
+ // ECS-aligned fields for Kibana Discover, Lens, alerts
316
+ const ecs: Record<string, unknown> = {
317
+ 'log.level': logLevel,
318
+ 'log.logger': this._name,
319
+ 'event.code': logEvent.code,
320
+ message: logEvent.msg,
321
+ service: {
322
+ name: process.env.SERVER_NICKNAME ?? 'unknown',
323
+ environment: process.env.NODE_ENV ?? 'development',
324
+ },
325
+ host: {
326
+ name: hostname(),
327
+ },
328
+ }
329
+
330
+ // Trace context for request-scoped correlation
331
+ const trace = getTraceContext()
332
+ if (trace) {
333
+ ;(ecs as Record<string, unknown>).trace = {id: trace.traceId}
334
+ }
335
+
336
+ // Structured context: flatten single plain object as top-level fields
337
+ let detail: unknown
338
+ if (
339
+ args.length === 1 &&
340
+ isPlainObject(args[0]) &&
341
+ Object.keys(args[0]).length > 0
342
+ ) {
343
+ const obj = args[0] as Record<string, unknown>
344
+ for (const [k, v] of Object.entries(obj)) {
345
+ const key = toSnakeCase(k)
346
+ ;(ecs as Record<string, unknown>)[key] = v
347
+ }
295
348
  } else {
296
- detail = JSON.stringify(args)
349
+ detail = isLocal ? args : JSON.stringify(args)
297
350
  }
298
- this._logger[logLevel]({
351
+
352
+ // Legacy fields for backward compatibility (component, code, msg)
353
+ const base: Record<string, unknown> = {
354
+ ...ecs,
299
355
  component: this._name,
300
- ...logEvent,
301
- detail,
302
- })
356
+ code: logEvent.code,
357
+ msg: logEvent.msg,
358
+ }
359
+ if (detail !== undefined) {
360
+ base.detail = detail
361
+ }
362
+ return base
363
+ }
364
+
365
+ private log(logLevel: LOG_LEVEL, logEvent: LogEvent, ...args: unknown[]) {
366
+ const payload = this.buildPayload(logLevel, logEvent, args)
367
+ this._logger[logLevel](payload)
303
368
  }
304
369
 
305
370
  /**
@@ -346,6 +411,43 @@ class Logger {
346
411
  trace(logEvent: LogEvent, ...args: unknown[]) {
347
412
  this.log('trace', logEvent, ...args)
348
413
  }
414
+
415
+ /**
416
+ * Runs an async operation and logs its duration.
417
+ * Adds event.duration (ms) for Kibana performance dashboards and alerts.
418
+ *
419
+ * @param logEvent - The event to log on completion
420
+ * @param fn - Async function to execute
421
+ * @param context - Optional context object (flattened as top-level fields)
422
+ * @returns Result of fn
423
+ */
424
+ async withDuration<T>(
425
+ logEvent: LogEvent,
426
+ fn: () => Promise<T>,
427
+ context?: Record<string, unknown>
428
+ ): Promise<T> {
429
+ const start = Date.now()
430
+ try {
431
+ const result = await fn()
432
+ const durationMs = Date.now() - start
433
+ const payload = this.buildPayload('info', logEvent, [
434
+ {...context, duration_ms: durationMs, success: true},
435
+ ])
436
+ this._logger.info(payload)
437
+ return result
438
+ } catch (error) {
439
+ const durationMs = Date.now() - start
440
+ const errObj =
441
+ error instanceof Error
442
+ ? {error_message: error.message, error_type: error.constructor.name}
443
+ : {}
444
+ const payload = this.buildPayload('error', logEvent, [
445
+ {...context, ...errObj, duration_ms: durationMs, success: false},
446
+ ])
447
+ this._logger.error(payload)
448
+ throw error
449
+ }
450
+ }
349
451
  }
350
452
 
351
453
  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
+ }