@backendkit-labs/observability 0.1.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,184 @@
1
+  Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and the Work's derivative works.
46
+
47
+ "Contribution" shall mean, as submitted to the Licensor for inclusion
48
+ in the Work by the copyright owner or by an individual or Legal Entity
49
+ authorized to submit on behalf of the copyright owner. For the purposes
50
+ of this definition, "submitted" means any form of electronic, verbal,
51
+ or written communication sent to the Licensor or its representatives,
52
+ including but not limited to communication on electronic mailing lists,
53
+ source code control systems, and issue tracking systems that are managed
54
+ by, or on behalf of, the Licensor for the purpose of discussing and
55
+ improving the Work, but excluding communication that is conspicuously
56
+ marked or designated in writing by the copyright owner as
57
+ "Not a Contribution."
58
+
59
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
60
+ whom a Contribution has been received by the Licensor and subsequently
61
+ incorporated within the Work.
62
+
63
+ 2. Grant of Copyright License. Subject to the terms and conditions of
64
+ this License, each Contributor hereby grants to You a perpetual,
65
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
66
+ copyright license to reproduce, prepare Derivative Works of,
67
+ publicly display, publicly perform, sublicense, and distribute the
68
+ Work and such Derivative Works in Source or Object form.
69
+
70
+ 3. Grant of Patent License. Subject to the terms and conditions of
71
+ this License, each Contributor hereby grants to You a perpetual,
72
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
73
+ (except as stated in this section) patent license to make, have made,
74
+ use, offer to sell, sell, import, and otherwise transfer the Work,
75
+ where such license applies only to those patent claims licensable
76
+ by such Contributor that are necessarily infringed by their
77
+ Contribution(s) alone or by the combination of their Contribution(s)
78
+ with the Work to which such Contribution(s) was submitted. If You
79
+ institute patent litigation against any entity (including a cross-claim
80
+ or counterclaim in a lawsuit) alleging that the Work or any
81
+ Contribution embodied within the Work constitutes direct or contributory
82
+ patent infringement, then any patent licenses granted to You under
83
+ this License for that Work shall terminate as of the date such
84
+ litigation is filed.
85
+
86
+ 4. Redistribution. You may reproduce and distribute copies of the
87
+ Work or Derivative Works thereof in any medium, with or without
88
+ modifications, and in Source or Object form, provided that You
89
+ meet the following conditions:
90
+
91
+ (a) You must give any other recipients of the Work or Derivative
92
+ Works a copy of this License; and
93
+
94
+ (b) You must cause any modified files to carry prominent notices
95
+ stating that You changed the files; and
96
+
97
+ (c) You must retain, in the Source form of any Derivative Works
98
+ that You distribute, all copyright, patent, trademark, and
99
+ attribution notices from the Source form of the Work,
100
+ excluding those notices that do not pertain to any part of
101
+ the Derivative Works; and
102
+
103
+ (d) If the Work includes a "NOTICE" text file as part of its
104
+ distribution, You must include a readable copy of the
105
+ attribution notices contained within such NOTICE file, in
106
+ at least one of the following places: within a NOTICE text
107
+ file distributed as part of the Derivative Works; within
108
+ the Source form or documentation, if provided along with the
109
+ Derivative Works; or, within a display generated by the
110
+ Derivative Works, if and wherever such third-party notices
111
+ normally appear. The contents of the NOTICE file are for
112
+ informational purposes only and do not modify the License.
113
+ You may add Your own attribution notices within Derivative
114
+ Works that You distribute, alongside or in addition to the
115
+ NOTICE text from the Work, provided that such additional
116
+ attribution notices cannot be construed as modifying the License.
117
+
118
+ You may add Your own license statement for Your modifications and
119
+ may provide additional grant of rights to use, copy, modify, and
120
+ distribute Your modifications, or for such Derivative Works as a
121
+ whole, subject to the terms and conditions of this License.
122
+
123
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
124
+ any Contribution intentionally submitted for inclusion in the Work
125
+ by You to the Licensor shall be under the terms and conditions of
126
+ this License, without any additional terms or conditions.
127
+ Notwithstanding the above, nothing herein shall supersede or modify
128
+ the terms of any separate license agreement you may have executed
129
+ with Licensor regarding such Contributions.
130
+
131
+ 6. Trademarks. This License does not grant permission to use the trade
132
+ names, trademarks, service marks, or product names of the Licensor,
133
+ except as required for reasonable and customary use in describing the
134
+ origin of the Work and reproducing the content of the NOTICE file.
135
+
136
+ 7. Disclaimer of Warranty. Unless required by applicable law or
137
+ agreed to in writing, Licensor provides the Work (and each
138
+ Contributor provides its Contributions) on an "AS IS" BASIS,
139
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
140
+ implied, including, without limitation, any warranties or conditions
141
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
142
+ PARTICULAR PURPOSE. You are solely responsible for determining the
143
+ appropriateness of using or reproducing the Work and assume any
144
+ risks associated with Your exercise of permissions under this License.
145
+
146
+ 8. Limitation of Liability. In no event and under no legal theory,
147
+ whether in tort (including negligence), contract, or otherwise,
148
+ unless required by applicable law (such as deliberate and grossly
149
+ negligent acts) or agreed to in writing, shall any Contributor be
150
+ liable to You for damages, including any direct, indirect, special,
151
+ incidental, or exemplary damages of any character arising as a
152
+ result of this License or out of the use or inability to use the
153
+ Work (including but not limited to damages for loss of goodwill,
154
+ work stoppage, computer failure or malfunction, or all other
155
+ commercial damages or losses), even if such Contributor has been
156
+ advised of the possibility of such damages.
157
+
158
+ 9. Accepting Warranty or Additional Liability. While redistributing
159
+ the Work or Derivative Works thereof, You may choose to offer,
160
+ and charge a fee for, acceptance of support, warranty, indemnity,
161
+ or other liability obligations and/or rights consistent with this
162
+ License. However, in accepting such obligations, You may act only
163
+ on Your own behalf and on Your sole responsibility, not on behalf
164
+ of any other Contributor, and only if You agree to indemnify,
165
+ defend, and hold each Contributor harmless for any liability
166
+ incurred by, or claims asserted against, such Contributor by reason
167
+ of your accepting any such warranty or additional liability.
168
+
169
+ END OF TERMS AND CONDITIONS
170
+
171
+ Copyright 2024-2026 Mairon José Cuello Martínez
172
+
173
+ Licensed under the Apache License, Version 2.0 (the "License");
174
+ you may not use this file except in compliance with the License.
175
+ You may obtain a copy of the License at
176
+
177
+ http://www.apache.org/licenses/LICENSE-2.0
178
+
179
+ Unless required by applicable law or agreed to in writing, software
180
+ distributed under the License is distributed on an "AS IS" BASIS,
181
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
182
+ See the License for the specific language governing permissions and
183
+ limitations under the License.
184
+
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @backendkit-labs/observability
2
2
 
3
+ [![Docs](https://img.shields.io/badge/docs-backendkitlabs.dev-4f7eff?style=flat-square)](https://backendkitlabs.dev/docs/observability/)
4
+
3
5
  Structured logging, distributed tracing correlation, metrics shipping, performance interceptors, and exception handling for **NestJS** — with optional OpenTelemetry integration.
4
6
 
5
7
  ## Features
@@ -364,4 +366,4 @@ The same `circuitBreaker` option is available on the `http` log transport.
364
366
 
365
367
  ## License
366
368
 
367
- MIT
369
+ Apache-2.0
package/dist/index.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  var common = require('@nestjs/common');
4
4
  var async_hooks = require('async_hooks');
5
- var crypto = require('crypto');
5
+ var module$1 = require('module');
6
6
  var winston = require('winston');
7
7
  var TransportStream = require('winston-transport');
8
8
  var axios = require('axios');
@@ -10,8 +10,10 @@ var http = require('http');
10
10
  var https = require('https');
11
11
  var circuitBreaker = require('@backendkit-labs/circuit-breaker');
12
12
  var rxjs = require('rxjs');
13
+ var crypto = require('crypto');
13
14
  var operators = require('rxjs/operators');
14
15
 
16
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
15
17
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
16
18
 
17
19
  function _interopNamespace(e) {
@@ -39,12 +41,6 @@ var http__namespace = /*#__PURE__*/_interopNamespace(http);
39
41
  var https__namespace = /*#__PURE__*/_interopNamespace(https);
40
42
 
41
43
  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
44
  var __decorateClass = (decorators, target, key, kind) => {
49
45
  var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
50
46
  for (var i = decorators.length - 1, decorator; i >= 0; i--)
@@ -53,11 +49,10 @@ var __decorateClass = (decorators, target, key, kind) => {
53
49
  return result;
54
50
  };
55
51
  var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
56
-
57
- // src/internal/otel.ts
52
+ var _require = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
58
53
  var otel = null;
59
54
  try {
60
- otel = __require("@opentelemetry/api");
55
+ otel = _require("@opentelemetry/api");
61
56
  } catch {
62
57
  }
63
58
  var getTracer = (name) => otel?.trace?.getTracer(name) ?? noopTracer;
@@ -95,9 +90,9 @@ exports.CorrelationIdService = class CorrelationIdService {
95
90
  run(correlationId, fn) {
96
91
  return storage.run(correlationId, fn);
97
92
  }
98
- /** Current correlation ID, or a fresh UUID when called outside a context. */
93
+ /** Current correlation ID, or 'no-context' when called outside a context. */
99
94
  get() {
100
- return storage.getStore() ?? crypto.randomUUID();
95
+ return storage.getStore() ?? "no-context";
101
96
  }
102
97
  /**
103
98
  * Current correlation ID, or `undefined` when called outside a context.
@@ -134,12 +129,18 @@ var TRANSPORT_CB_DEFAULTS = {
134
129
  halfOpenMaxCalls: 1,
135
130
  openTimeoutMs: 3e4
136
131
  };
137
- var WinstonHttpTransport = class extends TransportStream__default.default {
132
+ var WinstonHttpTransport = class _WinstonHttpTransport extends TransportStream__default.default {
138
133
  client;
139
134
  cb;
140
135
  buffer = [];
141
136
  batchSize;
142
137
  maxBufferSize;
138
+ maxEntryAgeMs = 3e5;
139
+ // 5 min
140
+ fallbackLogger = new common.Logger(_WinstonHttpTransport.name);
141
+ retryCounts = /* @__PURE__ */ new WeakMap();
142
+ entryTimes = /* @__PURE__ */ new WeakMap();
143
+ maxRetries = 5;
143
144
  flushTimer;
144
145
  constructor(opts) {
145
146
  super(opts);
@@ -158,11 +159,23 @@ var WinstonHttpTransport = class extends TransportStream__default.default {
158
159
  ...opts.headers
159
160
  }
160
161
  });
162
+ this.client.interceptors.response.use(
163
+ (response) => response,
164
+ (error) => {
165
+ if (error.config?.headers?.Authorization) {
166
+ error.config.headers.Authorization = "Bearer ***REDACTED***";
167
+ }
168
+ return Promise.reject(error);
169
+ }
170
+ );
161
171
  this.cb = new circuitBreaker.CircuitBreaker({
162
172
  ...TRANSPORT_CB_DEFAULTS,
163
173
  ...opts.circuitBreaker,
164
174
  name: "WinstonHttpTransport",
165
- isFailure: () => true
175
+ isFailure: (error) => {
176
+ const status = error.response?.status;
177
+ return status !== void 0 ? status >= 400 : true;
178
+ }
166
179
  });
167
180
  this.flushTimer = setInterval(
168
181
  () => {
@@ -174,9 +187,11 @@ var WinstonHttpTransport = class extends TransportStream__default.default {
174
187
  }
175
188
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
189
  log(info, callback) {
177
- setImmediate(() => this.emit("logged", info));
190
+ this.emit("logged", info);
178
191
  if (this.buffer.length < this.maxBufferSize) {
179
- this.buffer.push(info);
192
+ const entry = { ...info };
193
+ this.entryTimes.set(entry, Date.now());
194
+ this.buffer.push(entry);
180
195
  }
181
196
  if (this.buffer.length >= this.batchSize) {
182
197
  void this.flush();
@@ -190,14 +205,24 @@ var WinstonHttpTransport = class extends TransportStream__default.default {
190
205
  }
191
206
  async flush() {
192
207
  if (this.buffer.length === 0) return;
208
+ const now = Date.now();
209
+ this.buffer = this.buffer.filter(
210
+ (e) => now - (this.entryTimes.get(e) ?? now) < this.maxEntryAgeMs
211
+ );
193
212
  const batch = this.buffer.splice(0, this.batchSize);
194
213
  try {
195
214
  await this.cb.execute(() => this.client.post("", batch));
196
215
  } catch (err) {
216
+ const retryable = batch.filter((entry) => {
217
+ const retries = this.retryCounts.get(entry) ?? 0;
218
+ if (retries >= this.maxRetries) return false;
219
+ this.retryCounts.set(entry, retries + 1);
220
+ return true;
221
+ });
197
222
  const room = this.maxBufferSize - this.buffer.length;
198
- if (room > 0) this.buffer.unshift(...batch.slice(0, room));
223
+ if (room > 0) this.buffer.unshift(...retryable.slice(0, room));
199
224
  if (!(err instanceof circuitBreaker.CircuitBreakerOpenError)) {
200
- console.error(`[WinstonHttpTransport] flush failed \u2014 re-queued ${batch.length} entries`, err);
225
+ this.fallbackLogger.warn(`flush failed \u2014 re-queued ${retryable.length}/${batch.length} entries`, err.message);
201
226
  }
202
227
  }
203
228
  }
@@ -253,9 +278,10 @@ exports.LoggerService = class LoggerService {
253
278
  this.winston.verbose(message, this.buildMeta(context));
254
279
  }
255
280
  /** Log with additional arbitrary metadata. */
256
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
257
281
  logWithMeta(level, message, meta) {
258
- this.winston.log(level, message, { ...this.buildMeta(), ...meta });
282
+ const validLevels = ["error", "warn", "info", "debug", "verbose"];
283
+ const safeLevel = validLevels.includes(level) ? level : "info";
284
+ this.winston.log(safeLevel, message, { ...this.buildMeta(), ...meta });
259
285
  }
260
286
  buildMeta(context) {
261
287
  const base = {
@@ -305,7 +331,10 @@ exports.MetricsService = class MetricsService {
305
331
  ...TRANSPORT_CB_DEFAULTS2,
306
332
  ...m.circuitBreaker,
307
333
  name: "MetricsService",
308
- isFailure: () => true,
334
+ isFailure: (error) => {
335
+ const status = error.response?.status;
336
+ return status !== void 0 ? status >= 400 : true;
337
+ },
309
338
  onStateChange: (from, to, metrics) => {
310
339
  if (to === circuitBreaker.CircuitBreakerState.OPEN) {
311
340
  this.logger.warn(
@@ -333,6 +362,10 @@ exports.MetricsService = class MetricsService {
333
362
  logger = new common.Logger(exports.MetricsService.name);
334
363
  buffer = [];
335
364
  maxBufferSize = 5e3;
365
+ maxEntryAgeMs = 3e5;
366
+ // 5 min
367
+ retryCounts = /* @__PURE__ */ new WeakMap();
368
+ maxRetries = 5;
336
369
  flushTimer = null;
337
370
  /**
338
371
  * Enqueue a metric event. Fire-and-forget; batched and sent on the next
@@ -358,20 +391,33 @@ exports.MetricsService = class MetricsService {
358
391
  /** Flush on graceful shutdown. */
359
392
  async onModuleDestroy() {
360
393
  if (this.flushTimer) clearInterval(this.flushTimer);
361
- await this.flush();
394
+ try {
395
+ await this.flush();
396
+ } catch {
397
+ }
362
398
  }
363
399
  async flush() {
364
400
  if (!this.client || this.buffer.length === 0) return;
401
+ const now = Date.now();
402
+ this.buffer = this.buffer.filter(
403
+ (e) => now - new Date(e.timestamp).getTime() < this.maxEntryAgeMs
404
+ );
365
405
  const batch = this.buffer.splice(0, 500);
366
406
  try {
367
407
  await this.cb.execute(() => this.client.post("", batch));
368
408
  } catch (err) {
409
+ const retryable = batch.filter((entry) => {
410
+ const retries = this.retryCounts.get(entry) ?? 0;
411
+ if (retries >= this.maxRetries) return false;
412
+ this.retryCounts.set(entry, retries + 1);
413
+ return true;
414
+ });
369
415
  const room = this.maxBufferSize - this.buffer.length;
370
- if (room > 0) this.buffer.unshift(...batch.slice(0, room));
416
+ if (room > 0) this.buffer.unshift(...retryable.slice(0, room));
371
417
  if (!(err instanceof circuitBreaker.CircuitBreakerOpenError)) {
372
418
  this.logger.warn(
373
- `[MetricsService] flush failed \u2014 re-queueing ${batch.length} events`,
374
- err
419
+ `[MetricsService] flush failed \u2014 re-queueing ${retryable.length}/${batch.length} events`,
420
+ err.message
375
421
  );
376
422
  }
377
423
  }
@@ -383,6 +429,13 @@ exports.MetricsService = __decorateClass([
383
429
  __decorateParam(1, common.Optional())
384
430
  ], exports.MetricsService);
385
431
  var CORRELATION_HEADER = "x-correlation-id";
432
+ var CORRELATION_ID_REGEX = /^[a-zA-Z0-9\-_:]{1,64}$/;
433
+ function sanitizeCorrelationId(raw) {
434
+ if (typeof raw !== "string") return null;
435
+ if (raw.length > 64) return null;
436
+ if (!CORRELATION_ID_REGEX.test(raw)) return null;
437
+ return raw;
438
+ }
386
439
  exports.CorrelationInterceptor = class CorrelationInterceptor {
387
440
  constructor(correlationSvc) {
388
441
  this.correlationSvc = correlationSvc;
@@ -392,14 +445,11 @@ exports.CorrelationInterceptor = class CorrelationInterceptor {
392
445
  const req = ctx.switchToHttp().getRequest();
393
446
  const res = ctx.switchToHttp().getResponse();
394
447
  const incomingId = req.headers?.[CORRELATION_HEADER];
395
- const correlationId = typeof incomingId === "string" && incomingId ? incomingId : crypto.randomUUID();
448
+ const correlationId = sanitizeCorrelationId(incomingId) ?? crypto.randomUUID();
396
449
  res.setHeader(CORRELATION_HEADER, correlationId);
397
450
  return new rxjs.Observable((subscriber) => {
398
451
  this.correlationSvc.run(correlationId, () => {
399
- next.handle().pipe(
400
- operators.tap({ error: () => {
401
- } })
402
- ).subscribe(subscriber);
452
+ next.handle().subscribe(subscriber);
403
453
  });
404
454
  });
405
455
  }
@@ -507,7 +557,8 @@ exports.AllExceptionsFilter = class AllExceptionsFilter {
507
557
  }
508
558
  if (exception instanceof common.HttpException) {
509
559
  const body = exception.getResponse();
510
- const message = typeof body === "string" ? body : body.message ?? exception.message;
560
+ const rawMessage = typeof body === "string" ? body : body.message ?? exception.message;
561
+ const message = Array.isArray(rawMessage) ? rawMessage.join("; ") : String(rawMessage ?? exception.message);
511
562
  return { statusCode: exception.getStatus(), message };
512
563
  }
513
564
  return {