@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.
@@ -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
  });
@@ -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: indexName(),
59
+ refreshOnCompletion: false,
60
60
  onDocument(doc) {
61
61
  var _a, _b;
62
62
  const d = doc;
@@ -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
  */
@@ -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
- log(logLevel, logEvent, ...args) {
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 (process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'test') {
246
- detail = args;
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
- this._logger[logLevel]({
306
+ // Legacy fields for backward compatibility (component, code, msg)
307
+ const base = {
308
+ ...ecs,
252
309
  component: this._name,
253
- ...logEvent,
254
- detail,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhmdhammoud/meritt-utils",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
@@ -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
  })
@@ -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: indexName(),
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
- 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
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
- this._logger[logLevel]({
369
+
370
+ // Legacy fields for backward compatibility (component, code, msg)
371
+ const base: Record<string, unknown> = {
372
+ ...ecs,
299
373
  component: this._name,
300
- ...logEvent,
301
- detail,
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
+ }