@backendkit-labs/observability 0.1.0

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/index.cjs ADDED
@@ -0,0 +1,592 @@
1
+ 'use strict';
2
+
3
+ var common = require('@nestjs/common');
4
+ var async_hooks = require('async_hooks');
5
+ var crypto = require('crypto');
6
+ var winston = require('winston');
7
+ var TransportStream = require('winston-transport');
8
+ var axios = require('axios');
9
+ var http = require('http');
10
+ var https = require('https');
11
+ var circuitBreaker = require('@backendkit-labs/circuit-breaker');
12
+ var rxjs = require('rxjs');
13
+ var operators = require('rxjs/operators');
14
+
15
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
16
+
17
+ function _interopNamespace(e) {
18
+ if (e && e.__esModule) return e;
19
+ var n = Object.create(null);
20
+ if (e) {
21
+ Object.keys(e).forEach(function (k) {
22
+ if (k !== 'default') {
23
+ var d = Object.getOwnPropertyDescriptor(e, k);
24
+ Object.defineProperty(n, k, d.get ? d : {
25
+ enumerable: true,
26
+ get: function () { return e[k]; }
27
+ });
28
+ }
29
+ });
30
+ }
31
+ n.default = e;
32
+ return Object.freeze(n);
33
+ }
34
+
35
+ var winston__namespace = /*#__PURE__*/_interopNamespace(winston);
36
+ var TransportStream__default = /*#__PURE__*/_interopDefault(TransportStream);
37
+ var axios__default = /*#__PURE__*/_interopDefault(axios);
38
+ var http__namespace = /*#__PURE__*/_interopNamespace(http);
39
+ var https__namespace = /*#__PURE__*/_interopNamespace(https);
40
+
41
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
42
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
43
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
44
+ }) : x)(function(x) {
45
+ if (typeof require !== "undefined") return require.apply(this, arguments);
46
+ throw Error('Dynamic require of "' + x + '" is not supported');
47
+ });
48
+ var __decorateClass = (decorators, target, key, kind) => {
49
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
50
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
51
+ if (decorator = decorators[i])
52
+ result = (decorator(result)) || result;
53
+ return result;
54
+ };
55
+ var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
56
+
57
+ // src/internal/otel.ts
58
+ var otel = null;
59
+ try {
60
+ otel = __require("@opentelemetry/api");
61
+ } catch {
62
+ }
63
+ var getTracer = (name) => otel?.trace?.getTracer(name) ?? noopTracer;
64
+ var getActiveSpan = () => otel ? otel.trace.getSpan(otel.context.active()) : void 0;
65
+ var runInOtelContext = async (span, fn) => {
66
+ if (!otel || !span) return fn();
67
+ return otel.context.with(otel.trace.setSpan(otel.context.active(), span), fn);
68
+ };
69
+ var noopSpan = {
70
+ end: () => {
71
+ },
72
+ setAttribute: () => {
73
+ },
74
+ setAttributes: () => {
75
+ },
76
+ recordException: () => {
77
+ },
78
+ setStatus: () => {
79
+ },
80
+ spanContext: () => ({ traceId: "", spanId: "" })
81
+ };
82
+ var noopTracer = {
83
+ startSpan: () => noopSpan,
84
+ startActiveSpan: (_name, fn) => fn(noopSpan)
85
+ };
86
+
87
+ // src/correlation/correlation.service.ts
88
+ var storage = new async_hooks.AsyncLocalStorage();
89
+ exports.CorrelationIdService = class CorrelationIdService {
90
+ /**
91
+ * Run `fn` inside a context that carries `correlationId`.
92
+ * All code executed within `fn` (including async continuations) can call
93
+ * `get()` and receive the same ID without passing it explicitly.
94
+ */
95
+ run(correlationId, fn) {
96
+ return storage.run(correlationId, fn);
97
+ }
98
+ /** Current correlation ID, or a fresh UUID when called outside a context. */
99
+ get() {
100
+ return storage.getStore() ?? crypto.randomUUID();
101
+ }
102
+ /**
103
+ * Current correlation ID, or `undefined` when called outside a context.
104
+ * Prefer `get()` for logging; use this only when you need to distinguish
105
+ * "no context" from "context with a random ID".
106
+ */
107
+ getOrUndefined() {
108
+ return storage.getStore();
109
+ }
110
+ /**
111
+ * Active OTel trace + span IDs when @opentelemetry/api is installed and a
112
+ * span is active; `undefined` otherwise.
113
+ */
114
+ getTraceContext() {
115
+ const span = getActiveSpan();
116
+ if (!span) return void 0;
117
+ const ctx = span.spanContext?.();
118
+ if (!ctx?.traceId) return void 0;
119
+ return { traceId: ctx.traceId, spanId: ctx.spanId };
120
+ }
121
+ };
122
+ exports.CorrelationIdService = __decorateClass([
123
+ common.Injectable()
124
+ ], exports.CorrelationIdService);
125
+
126
+ // src/observability.constants.ts
127
+ var OBSERVABILITY_OPTIONS = "OBSERVABILITY_OPTIONS";
128
+ var TRANSPORT_CB_DEFAULTS = {
129
+ failureThreshold: 60,
130
+ slowCallThreshold: 100,
131
+ slowCallDurationMs: 6e4,
132
+ minimumCalls: 3,
133
+ slidingWindowSize: 5,
134
+ halfOpenMaxCalls: 1,
135
+ openTimeoutMs: 3e4
136
+ };
137
+ var WinstonHttpTransport = class extends TransportStream__default.default {
138
+ client;
139
+ cb;
140
+ buffer = [];
141
+ batchSize;
142
+ maxBufferSize;
143
+ flushTimer;
144
+ constructor(opts) {
145
+ super(opts);
146
+ this.batchSize = opts.batchSize ?? 100;
147
+ this.maxBufferSize = opts.maxBufferSize ?? 2e3;
148
+ const keepAlive = new http__namespace.Agent({ keepAlive: true });
149
+ const keepAliveHttps = new https__namespace.Agent({ keepAlive: true });
150
+ this.client = axios__default.default.create({
151
+ baseURL: opts.url,
152
+ timeout: opts.timeoutMs ?? 5e3,
153
+ httpAgent: keepAlive,
154
+ httpsAgent: keepAliveHttps,
155
+ headers: {
156
+ "Content-Type": "application/json",
157
+ ...opts.authToken ? { Authorization: `Bearer ${opts.authToken}` } : {},
158
+ ...opts.headers
159
+ }
160
+ });
161
+ this.cb = new circuitBreaker.CircuitBreaker({
162
+ ...TRANSPORT_CB_DEFAULTS,
163
+ ...opts.circuitBreaker,
164
+ name: "WinstonHttpTransport",
165
+ isFailure: () => true
166
+ });
167
+ this.flushTimer = setInterval(
168
+ () => {
169
+ void this.flush();
170
+ },
171
+ opts.flushIntervalMs ?? 5e3
172
+ );
173
+ this.flushTimer.unref?.();
174
+ }
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ log(info, callback) {
177
+ setImmediate(() => this.emit("logged", info));
178
+ if (this.buffer.length < this.maxBufferSize) {
179
+ this.buffer.push(info);
180
+ }
181
+ if (this.buffer.length >= this.batchSize) {
182
+ void this.flush();
183
+ }
184
+ callback();
185
+ }
186
+ /** Flush remaining buffer on graceful shutdown. */
187
+ async close() {
188
+ clearInterval(this.flushTimer);
189
+ await this.flush();
190
+ }
191
+ async flush() {
192
+ if (this.buffer.length === 0) return;
193
+ const batch = this.buffer.splice(0, this.batchSize);
194
+ try {
195
+ await this.cb.execute(() => this.client.post("", batch));
196
+ } catch (err) {
197
+ const room = this.maxBufferSize - this.buffer.length;
198
+ if (room > 0) this.buffer.unshift(...batch.slice(0, room));
199
+ if (!(err instanceof circuitBreaker.CircuitBreakerOpenError)) {
200
+ console.error(`[WinstonHttpTransport] flush failed \u2014 re-queued ${batch.length} entries`, err);
201
+ }
202
+ }
203
+ }
204
+ };
205
+
206
+ // src/logger/logger.service.ts
207
+ exports.LoggerService = class LoggerService {
208
+ constructor(opts, correlationSvc) {
209
+ this.opts = opts;
210
+ this.correlationSvc = correlationSvc;
211
+ const transports2 = [
212
+ new winston__namespace.transports.Console({
213
+ format: winston__namespace.format.combine(
214
+ winston__namespace.format.timestamp(),
215
+ winston__namespace.format.colorize(),
216
+ winston__namespace.format.printf(({ level, message, timestamp, ...meta }) => {
217
+ const extra = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
218
+ return `${timestamp} [${level}] ${message}${extra}`;
219
+ })
220
+ )
221
+ })
222
+ ];
223
+ if (opts.http) {
224
+ transports2.push(
225
+ new WinstonHttpTransport({
226
+ ...opts.http,
227
+ format: winston__namespace.format.json()
228
+ })
229
+ );
230
+ }
231
+ this.winston = winston__namespace.createLogger({
232
+ level: opts.logLevel ?? "info",
233
+ transports: transports2,
234
+ format: winston__namespace.format.json()
235
+ });
236
+ }
237
+ opts;
238
+ correlationSvc;
239
+ winston;
240
+ log(message, context) {
241
+ this.winston.info(message, this.buildMeta(context));
242
+ }
243
+ error(message, trace, context) {
244
+ this.winston.error(message, { ...this.buildMeta(context), trace });
245
+ }
246
+ warn(message, context) {
247
+ this.winston.warn(message, this.buildMeta(context));
248
+ }
249
+ debug(message, context) {
250
+ this.winston.debug(message, this.buildMeta(context));
251
+ }
252
+ verbose(message, context) {
253
+ this.winston.verbose(message, this.buildMeta(context));
254
+ }
255
+ /** Log with additional arbitrary metadata. */
256
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
257
+ logWithMeta(level, message, meta) {
258
+ this.winston.log(level, message, { ...this.buildMeta(), ...meta });
259
+ }
260
+ buildMeta(context) {
261
+ const base = {
262
+ service: this.opts.serviceName,
263
+ environment: this.opts.environment ?? "production",
264
+ correlationId: this.correlationSvc?.get()
265
+ };
266
+ if (context) base.context = context;
267
+ return base;
268
+ }
269
+ };
270
+ exports.LoggerService = __decorateClass([
271
+ common.Injectable(),
272
+ __decorateParam(0, common.Inject(OBSERVABILITY_OPTIONS)),
273
+ __decorateParam(1, common.Optional())
274
+ ], exports.LoggerService);
275
+ var TRANSPORT_CB_DEFAULTS2 = {
276
+ failureThreshold: 60,
277
+ slowCallThreshold: 100,
278
+ slowCallDurationMs: 6e4,
279
+ minimumCalls: 3,
280
+ slidingWindowSize: 5,
281
+ halfOpenMaxCalls: 1,
282
+ openTimeoutMs: 3e4
283
+ };
284
+ exports.MetricsService = class MetricsService {
285
+ constructor(opts, correlationSvc) {
286
+ this.opts = opts;
287
+ this.correlationSvc = correlationSvc;
288
+ if (!opts.metrics) return;
289
+ const m = opts.metrics;
290
+ this.maxBufferSize = m.maxBufferSize ?? 5e3;
291
+ const keepAlive = new http__namespace.Agent({ keepAlive: true });
292
+ const keepAliveHttps = new https__namespace.Agent({ keepAlive: true });
293
+ this.client = axios__default.default.create({
294
+ baseURL: m.url,
295
+ timeout: m.timeoutMs ?? 5e3,
296
+ httpAgent: keepAlive,
297
+ httpsAgent: keepAliveHttps,
298
+ headers: {
299
+ "Content-Type": "application/json",
300
+ ...m.authToken ? { Authorization: `Bearer ${m.authToken}` } : {},
301
+ ...m.headers
302
+ }
303
+ });
304
+ this.cb = new circuitBreaker.CircuitBreaker({
305
+ ...TRANSPORT_CB_DEFAULTS2,
306
+ ...m.circuitBreaker,
307
+ name: "MetricsService",
308
+ isFailure: () => true,
309
+ onStateChange: (from, to, metrics) => {
310
+ if (to === circuitBreaker.CircuitBreakerState.OPEN) {
311
+ this.logger.warn(
312
+ `[MetricsService] circuit breaker OPEN \u2014 pausing metric sends for ${(m.circuitBreaker?.openTimeoutMs ?? TRANSPORT_CB_DEFAULTS2.openTimeoutMs) / 1e3}s`,
313
+ metrics
314
+ );
315
+ } else if (to === circuitBreaker.CircuitBreakerState.CLOSED && from !== circuitBreaker.CircuitBreakerState.HALF_OPEN) {
316
+ this.logger.log(`[MetricsService] circuit breaker CLOSED \u2014 recovered`);
317
+ }
318
+ m.circuitBreaker?.onStateChange?.(from, to, metrics);
319
+ }
320
+ });
321
+ this.flushTimer = setInterval(
322
+ () => {
323
+ void this.flush();
324
+ },
325
+ m.flushIntervalMs ?? 1e4
326
+ );
327
+ this.flushTimer.unref?.();
328
+ }
329
+ opts;
330
+ correlationSvc;
331
+ client = null;
332
+ cb = null;
333
+ logger = new common.Logger(exports.MetricsService.name);
334
+ buffer = [];
335
+ maxBufferSize = 5e3;
336
+ flushTimer = null;
337
+ /**
338
+ * Enqueue a metric event. Fire-and-forget; batched and sent on the next
339
+ * flush interval or when the buffer reaches `maxBufferSize`.
340
+ */
341
+ record(name, value, options) {
342
+ if (!this.client) return;
343
+ if (this.buffer.length >= this.maxBufferSize) {
344
+ this.logger.warn("[MetricsService] buffer full \u2014 dropping metric");
345
+ return;
346
+ }
347
+ this.buffer.push({
348
+ name,
349
+ value,
350
+ unit: options?.unit,
351
+ tags: options?.tags,
352
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
353
+ serviceName: this.opts.serviceName,
354
+ environment: this.opts.environment ?? "production",
355
+ correlationId: this.correlationSvc?.getOrUndefined()
356
+ });
357
+ }
358
+ /** Flush on graceful shutdown. */
359
+ async onModuleDestroy() {
360
+ if (this.flushTimer) clearInterval(this.flushTimer);
361
+ await this.flush();
362
+ }
363
+ async flush() {
364
+ if (!this.client || this.buffer.length === 0) return;
365
+ const batch = this.buffer.splice(0, 500);
366
+ try {
367
+ await this.cb.execute(() => this.client.post("", batch));
368
+ } catch (err) {
369
+ const room = this.maxBufferSize - this.buffer.length;
370
+ if (room > 0) this.buffer.unshift(...batch.slice(0, room));
371
+ if (!(err instanceof circuitBreaker.CircuitBreakerOpenError)) {
372
+ this.logger.warn(
373
+ `[MetricsService] flush failed \u2014 re-queueing ${batch.length} events`,
374
+ err
375
+ );
376
+ }
377
+ }
378
+ }
379
+ };
380
+ exports.MetricsService = __decorateClass([
381
+ common.Injectable(),
382
+ __decorateParam(0, common.Inject(OBSERVABILITY_OPTIONS)),
383
+ __decorateParam(1, common.Optional())
384
+ ], exports.MetricsService);
385
+ var CORRELATION_HEADER = "x-correlation-id";
386
+ exports.CorrelationInterceptor = class CorrelationInterceptor {
387
+ constructor(correlationSvc) {
388
+ this.correlationSvc = correlationSvc;
389
+ }
390
+ correlationSvc;
391
+ intercept(ctx, next) {
392
+ const req = ctx.switchToHttp().getRequest();
393
+ const res = ctx.switchToHttp().getResponse();
394
+ const incomingId = req.headers?.[CORRELATION_HEADER];
395
+ const correlationId = typeof incomingId === "string" && incomingId ? incomingId : crypto.randomUUID();
396
+ res.setHeader(CORRELATION_HEADER, correlationId);
397
+ return new rxjs.Observable((subscriber) => {
398
+ this.correlationSvc.run(correlationId, () => {
399
+ next.handle().pipe(
400
+ operators.tap({ error: () => {
401
+ } })
402
+ ).subscribe(subscriber);
403
+ });
404
+ });
405
+ }
406
+ };
407
+ exports.CorrelationInterceptor = __decorateClass([
408
+ common.Injectable(),
409
+ __decorateParam(0, common.Inject(exports.CorrelationIdService))
410
+ ], exports.CorrelationInterceptor);
411
+ exports.PerformanceInterceptor = class PerformanceInterceptor {
412
+ constructor(logger, metrics, correlationSvc) {
413
+ this.logger = logger;
414
+ this.metrics = metrics;
415
+ this.correlationSvc = correlationSvc;
416
+ }
417
+ logger;
418
+ metrics;
419
+ correlationSvc;
420
+ intercept(ctx, next) {
421
+ const start = Date.now();
422
+ const req = ctx.switchToHttp().getRequest();
423
+ const method = req.method ?? "UNKNOWN";
424
+ const path = req.url ?? "UNKNOWN";
425
+ return next.handle().pipe(
426
+ operators.tap({
427
+ next: () => {
428
+ const durationMs = Date.now() - start;
429
+ this.record(method, path, "success", durationMs);
430
+ },
431
+ error: () => {
432
+ const durationMs = Date.now() - start;
433
+ this.record(method, path, "error", durationMs);
434
+ }
435
+ })
436
+ );
437
+ }
438
+ record(method, path, outcome, durationMs) {
439
+ const correlationId = this.correlationSvc?.getOrUndefined();
440
+ const span = getActiveSpan();
441
+ span?.setAttribute("http.method", method);
442
+ span?.setAttribute("http.target", path);
443
+ span?.setAttribute("http.duration_ms", durationMs);
444
+ this.logger.logWithMeta("info", `${method} ${path} [${outcome}] ${durationMs}ms`, {
445
+ method,
446
+ path,
447
+ outcome,
448
+ durationMs,
449
+ correlationId
450
+ });
451
+ this.metrics?.record("http.request.duration", durationMs, {
452
+ unit: "ms",
453
+ tags: { method, path, outcome }
454
+ });
455
+ }
456
+ };
457
+ exports.PerformanceInterceptor = __decorateClass([
458
+ common.Injectable(),
459
+ __decorateParam(0, common.Inject(exports.LoggerService)),
460
+ __decorateParam(1, common.Optional()),
461
+ __decorateParam(1, common.Inject(exports.MetricsService)),
462
+ __decorateParam(2, common.Optional()),
463
+ __decorateParam(2, common.Inject(exports.CorrelationIdService))
464
+ ], exports.PerformanceInterceptor);
465
+ exports.AllExceptionsFilter = class AllExceptionsFilter {
466
+ constructor(logger, correlationSvc) {
467
+ this.logger = logger;
468
+ this.correlationSvc = correlationSvc;
469
+ }
470
+ logger;
471
+ correlationSvc;
472
+ mappers = [];
473
+ /**
474
+ * Register a custom error mapper.
475
+ * Mappers are tried in order; the first non-null result wins.
476
+ * Return `null` to fall through to the next mapper.
477
+ */
478
+ addMapper(mapper) {
479
+ this.mappers.push(mapper);
480
+ return this;
481
+ }
482
+ catch(exception, host) {
483
+ const ctx = host.switchToHttp();
484
+ const res = ctx.getResponse();
485
+ const mapped = this.resolveError(exception);
486
+ const correlationId = this.correlationSvc?.getOrUndefined();
487
+ const span = getActiveSpan();
488
+ span?.recordException(exception instanceof Error ? exception : new Error(String(exception)));
489
+ this.logger.error(
490
+ mapped.message,
491
+ exception instanceof Error ? exception.stack : void 0,
492
+ exports.AllExceptionsFilter.name
493
+ );
494
+ res.status(mapped.statusCode).json({
495
+ ok: false,
496
+ statusCode: mapped.statusCode,
497
+ message: mapped.message,
498
+ code: mapped.code,
499
+ correlationId,
500
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
501
+ });
502
+ }
503
+ resolveError(exception) {
504
+ for (const mapper of this.mappers) {
505
+ const result = mapper(exception);
506
+ if (result !== null) return result;
507
+ }
508
+ if (exception instanceof common.HttpException) {
509
+ const body = exception.getResponse();
510
+ const message = typeof body === "string" ? body : body.message ?? exception.message;
511
+ return { statusCode: exception.getStatus(), message };
512
+ }
513
+ return {
514
+ statusCode: common.HttpStatus.INTERNAL_SERVER_ERROR,
515
+ message: "Internal server error"
516
+ };
517
+ }
518
+ };
519
+ exports.AllExceptionsFilter = __decorateClass([
520
+ common.Catch(),
521
+ __decorateParam(0, common.Inject(exports.LoggerService)),
522
+ __decorateParam(1, common.Optional()),
523
+ __decorateParam(1, common.Inject(exports.CorrelationIdService))
524
+ ], exports.AllExceptionsFilter);
525
+
526
+ // src/observability.module.ts
527
+ exports.ObservabilityModule = class ObservabilityModule {
528
+ static forRoot(options) {
529
+ const optionsProvider = {
530
+ provide: OBSERVABILITY_OPTIONS,
531
+ useValue: options
532
+ };
533
+ const providers = [
534
+ optionsProvider,
535
+ exports.CorrelationIdService,
536
+ exports.LoggerService,
537
+ exports.MetricsService,
538
+ exports.CorrelationInterceptor,
539
+ exports.PerformanceInterceptor,
540
+ exports.AllExceptionsFilter
541
+ ];
542
+ return {
543
+ module: exports.ObservabilityModule,
544
+ global: true,
545
+ providers,
546
+ exports: [
547
+ exports.CorrelationIdService,
548
+ exports.LoggerService,
549
+ exports.MetricsService,
550
+ exports.CorrelationInterceptor,
551
+ exports.PerformanceInterceptor,
552
+ exports.AllExceptionsFilter
553
+ ]
554
+ };
555
+ }
556
+ };
557
+ exports.ObservabilityModule = __decorateClass([
558
+ common.Module({})
559
+ ], exports.ObservabilityModule);
560
+
561
+ // src/decorators/track-performance.decorator.ts
562
+ function TrackPerformance(options = {}) {
563
+ return function(target, propertyKey, descriptor) {
564
+ const original = descriptor.value;
565
+ const operationName = options.operation ?? `${target.constructor.name}.${String(propertyKey)}`;
566
+ descriptor.value = async function(...args) {
567
+ const tracer = getTracer("@backendkit-labs/observability");
568
+ const span = tracer.startSpan(operationName);
569
+ if (options.attributes) {
570
+ span.setAttributes(options.attributes);
571
+ }
572
+ try {
573
+ const result = await runInOtelContext(span, () => original.apply(this, args));
574
+ span.setStatus({ code: 1 });
575
+ return result;
576
+ } catch (err) {
577
+ span.recordException(err instanceof Error ? err : new Error(String(err)));
578
+ span.setStatus({ code: 2, message: String(err) });
579
+ throw err;
580
+ } finally {
581
+ span.end();
582
+ }
583
+ };
584
+ return descriptor;
585
+ };
586
+ }
587
+
588
+ exports.OBSERVABILITY_OPTIONS = OBSERVABILITY_OPTIONS;
589
+ exports.TrackPerformance = TrackPerformance;
590
+ exports.WinstonHttpTransport = WinstonHttpTransport;
591
+ //# sourceMappingURL=index.cjs.map
592
+ //# sourceMappingURL=index.cjs.map