kohi-node 0.1.3 → 0.1.4

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.d.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  import { IncomingMessage, ServerResponse } from 'http';
4
4
  import { gzip } from 'zlib';
5
5
 
6
- export declare const VERSION = "node:0.1.3";
7
6
  export declare const DEFAULT_INGEST_ENDPOINT = "https://kohicorp.com/api/ingest";
8
7
  export declare const DEFAULT_MAX_CONCURRENT_SENDS = 5;
9
8
  export declare const DEFAULT_QUEUE_SIZE = 5000;
@@ -76,16 +75,78 @@ export declare class Monitor {
76
75
  addEvent(evt: Event$1): void;
77
76
  shutdown(): Promise<void>;
78
77
  close(): Promise<void>;
78
+ captureException(error: Error, opts?: {
79
+ fingerprint?: string;
80
+ }): void;
81
+ captureExceptionWithRequest(error: Error, reqURL: string, reqMethod: string, clientIP?: string): void;
82
+ captureMessage(msg: string, level?: "fatal" | "error" | "warning"): void;
83
+ captureHttpError(statusCode: number, responseBody: string, reqURL: string, reqMethod: string, clientIP?: string): void;
84
+ private enqueue;
79
85
  private startWorker;
80
86
  private sendBatch;
81
87
  private sendBatchAttempt;
82
88
  }
83
89
  export declare function createMonitor(config: Config): Monitor;
90
+ export interface StackFrame {
91
+ file: string;
92
+ line: number;
93
+ column?: number;
94
+ function: string;
95
+ context?: string[];
96
+ in_app: boolean;
97
+ }
98
+ export interface ErrorEvent {
99
+ _type: "error";
100
+ error_type: string;
101
+ error_message: string;
102
+ level: "fatal" | "error" | "warning";
103
+ fingerprint?: string;
104
+ stack_frames: StackFrame[];
105
+ request_url?: string;
106
+ request_method?: string;
107
+ timestamp: number;
108
+ client_ip?: string;
109
+ }
110
+ export declare function parseNodeStack(stack: string): StackFrame[];
111
+ declare let _monitorRef: {
112
+ captureException(err: Error): void;
113
+ captureExceptionWithRequest(err: Error, url: string, method: string, clientIP?: string): void;
114
+ isEnabled(): boolean;
115
+ } | null;
116
+ /**
117
+ * Installs process-level error handlers for uncaught exceptions and unhandled
118
+ * rejections. Captured errors are queued for the next batch send.
119
+ *
120
+ * Uses AsyncLocalStorage to propagate request context — async errors thrown
121
+ * inside request handlers are captured with the originating URL and method.
122
+ *
123
+ * NOTE: Adding an `uncaughtException` listener prevents Node.js from
124
+ * terminating the process on unhandled exceptions. If your application relies
125
+ * on crash-and-restart behavior (e.g., with PM2 or systemd), you should still
126
+ * call `process.exit(1)` in your own `uncaughtException` handler after the
127
+ * Kohi handler runs.
128
+ */
129
+ export declare function installGlobalHooks(monitor: typeof _monitorRef): void;
84
130
  export declare function expressMiddleware(monitor: Monitor): (req: IncomingMessage, res: ServerResponse, next: () => void) => void;
131
+ export declare function expressErrorHandler(monitor: Monitor): (err: Error, req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
85
132
  export declare function instrumentNextJs(cfg: Config): void;
133
+ /**
134
+ * Next.js onRequestError handler for instrumentation.ts.
135
+ * Usage in instrumentation.ts:
136
+ * import { onRequestError } from '@kohi/node';
137
+ * export { onRequestError };
138
+ */
139
+ export declare function onRequestError(err: {
140
+ digest: string;
141
+ } & Error, request: {
142
+ path: string;
143
+ method: string;
144
+ headers: Record<string, string | string[]>;
145
+ }, _context: unknown): void;
86
146
  export declare function init(cfg: Config): {
87
147
  monitor: Monitor;
88
148
  expressMiddleware: () => (req: import("http").IncomingMessage, res: import("http").ServerResponse, next: () => void) => void;
149
+ expressErrorHandler: () => (err: Error, req: import("http").IncomingMessage, res: import("http").ServerResponse, next: (err?: unknown) => void) => void;
89
150
  instrumentNextJs: () => void;
90
151
  };
91
152
  declare const defaultExport: typeof Monitor & {
package/dist/index.js CHANGED
@@ -37,22 +37,25 @@ __export(index_exports, {
37
37
  Monitor: () => Monitor,
38
38
  REDACTION_MASK: () => REDACTION_MASK,
39
39
  SENSITIVE_KEYS: () => SENSITIVE_KEYS,
40
- VERSION: () => VERSION,
41
40
  canonicalHeaders: () => canonicalHeaders,
42
41
  createMonitor: () => createMonitor,
43
42
  default: () => index_default,
43
+ expressErrorHandler: () => expressErrorHandler,
44
44
  expressMiddleware: () => expressMiddleware,
45
45
  extractClientIP: () => extractClientIP,
46
46
  firstIPFromList: () => firstIPFromList,
47
47
  gzipAsync: () => gzipAsync,
48
48
  init: () => init,
49
+ installGlobalHooks: () => installGlobalHooks,
49
50
  instrumentNextJs: () => instrumentNextJs,
50
51
  isRetryableStatus: () => isRetryableStatus,
51
52
  isValidIP: () => isValidIP,
52
53
  jitter: () => jitter,
53
54
  normalizeIP: () => normalizeIP,
55
+ onRequestError: () => onRequestError,
54
56
  parseForwardedHeader: () => parseForwardedHeader,
55
57
  parseJSONBody: () => parseJSONBody,
58
+ parseNodeStack: () => parseNodeStack,
56
59
  peerIPFromRemoteAddr: () => peerIPFromRemoteAddr,
57
60
  redactEvent: () => redactEvent,
58
61
  redactHeaders: () => redactHeaders,
@@ -67,8 +70,8 @@ module.exports = __toCommonJS(index_exports);
67
70
  var import_crypto = require("crypto");
68
71
  var import_zlib = require("zlib");
69
72
  var import_util = require("util");
70
- var VERSION = "node:0.1.3";
71
73
  var DEFAULT_INGEST_ENDPOINT = "https://kohicorp.com/api/ingest";
74
+ var MAX_REQUEST_BODY = 64 * 1024;
72
75
  var MAX_RESPONSE_BODY = 64 * 1024;
73
76
  var DEFAULT_MAX_CONCURRENT_SENDS = 5;
74
77
  var DEFAULT_QUEUE_SIZE = 5e3;
@@ -190,6 +193,70 @@ function parseJSONBody(raw) {
190
193
  }
191
194
  }
192
195
 
196
+ // src/errors.ts
197
+ var import_node_async_hooks = require("node:async_hooks");
198
+ function parseNodeStack(stack) {
199
+ const frames = [];
200
+ for (const line of stack.split("\n")) {
201
+ const trimmed = line.trim();
202
+ if (!trimmed.startsWith("at ")) continue;
203
+ const frame = parseStackLine(trimmed.slice(3));
204
+ if (frame) frames.push(frame);
205
+ }
206
+ return frames;
207
+ }
208
+ function parseStackLine(line) {
209
+ const parenMatch = line.match(/^(.+?)\s+\((.+):(\d+):(\d+)\)$/);
210
+ if (parenMatch) {
211
+ return { function: parenMatch[1], file: parenMatch[2], line: +parenMatch[3], column: +parenMatch[4], in_app: isInApp(parenMatch[2]) };
212
+ }
213
+ const anonMatch = line.match(/^(.+):(\d+):(\d+)$/);
214
+ if (anonMatch) {
215
+ return { function: "<anonymous>", file: anonMatch[1], line: +anonMatch[2], column: +anonMatch[3], in_app: isInApp(anonMatch[1]) };
216
+ }
217
+ return null;
218
+ }
219
+ function isInApp(file) {
220
+ return !file.includes("node_modules/") && !file.includes("node_modules\\") && !file.startsWith("node:") && !file.startsWith("internal/");
221
+ }
222
+ function buildErrorEvent(err, level = "error", reqURL, reqMethod, clientIP) {
223
+ const evt = {
224
+ _type: "error",
225
+ error_type: err.constructor?.name ?? "Error",
226
+ error_message: err.message,
227
+ level,
228
+ stack_frames: err.stack ? parseNodeStack(err.stack) : [],
229
+ request_url: reqURL,
230
+ request_method: reqMethod,
231
+ timestamp: Date.now()
232
+ };
233
+ if (clientIP) evt.client_ip = clientIP;
234
+ return evt;
235
+ }
236
+ var requestContext = new import_node_async_hooks.AsyncLocalStorage();
237
+ var _hooksInstalled = false;
238
+ var _monitorRef = null;
239
+ function installGlobalHooks(monitor) {
240
+ _monitorRef = monitor;
241
+ if (_hooksInstalled) return;
242
+ _hooksInstalled = true;
243
+ const capture = (raw) => {
244
+ if (!_monitorRef?.isEnabled()) return;
245
+ const error = raw instanceof Error ? raw : new Error(String(raw));
246
+ const reqCtx = requestContext.getStore();
247
+ try {
248
+ if (reqCtx) {
249
+ _monitorRef.captureExceptionWithRequest(error, reqCtx.url, reqCtx.method, reqCtx.clientIP);
250
+ } else {
251
+ _monitorRef.captureException(error);
252
+ }
253
+ } catch {
254
+ }
255
+ };
256
+ process.on("uncaughtException", capture);
257
+ process.on("unhandledRejection", capture);
258
+ }
259
+
193
260
  // src/monitor.ts
194
261
  var Monitor = class _Monitor {
195
262
  projectKey;
@@ -218,7 +285,10 @@ var Monitor = class _Monitor {
218
285
  this.secret = Buffer.from(config.secretKey, "base64url");
219
286
  this.logger = config.logger ?? noopLogger;
220
287
  this.enabled = enabled;
221
- if (this.enabled) this.startWorker();
288
+ if (this.enabled) {
289
+ this.startWorker();
290
+ installGlobalHooks(this);
291
+ }
222
292
  }
223
293
  static noop() {
224
294
  return new _Monitor({ projectKey: "pk_00000000000000000000AA", secretKey: "0000000000000000000000000000000000000000000", enabled: false });
@@ -229,11 +299,7 @@ var Monitor = class _Monitor {
229
299
  addEvent(evt) {
230
300
  if (!this.enabled || this.closed) return;
231
301
  const normalized = { ...evt, method: evt.method.toUpperCase(), request_headers: canonicalHeaders(evt.request_headers), response_headers: canonicalHeaders(evt.response_headers) };
232
- if (this.queue.length >= this.queueSize) {
233
- this.logger.warn("kohi monitor queue is full; dropping event");
234
- return;
235
- }
236
- this.queue.push(normalized);
302
+ this.enqueue(normalized);
237
303
  }
238
304
  async shutdown() {
239
305
  if (this.closed) return;
@@ -244,6 +310,51 @@ var Monitor = class _Monitor {
244
310
  async close() {
245
311
  return this.shutdown();
246
312
  }
313
+ captureException(error, opts) {
314
+ if (!this.enabled || this.closed) return;
315
+ const evt = buildErrorEvent(error, "error");
316
+ if (opts?.fingerprint) evt.fingerprint = opts.fingerprint;
317
+ this.enqueue(evt);
318
+ }
319
+ captureExceptionWithRequest(error, reqURL, reqMethod, clientIP) {
320
+ if (!this.enabled || this.closed) return;
321
+ const evt = buildErrorEvent(error, "error", reqURL, reqMethod, clientIP);
322
+ this.enqueue(evt);
323
+ }
324
+ captureMessage(msg, level = "error") {
325
+ if (!this.enabled || this.closed) return;
326
+ const evt = {
327
+ _type: "error",
328
+ error_type: "Message",
329
+ error_message: msg,
330
+ level,
331
+ stack_frames: [],
332
+ timestamp: Date.now()
333
+ };
334
+ this.enqueue(evt);
335
+ }
336
+ captureHttpError(statusCode, responseBody, reqURL, reqMethod, clientIP) {
337
+ if (!this.enabled || this.closed) return;
338
+ const evt = {
339
+ _type: "error",
340
+ error_type: `HTTP ${statusCode}`,
341
+ error_message: responseBody,
342
+ level: "error",
343
+ stack_frames: [],
344
+ request_url: reqURL,
345
+ request_method: reqMethod,
346
+ timestamp: Date.now()
347
+ };
348
+ if (clientIP) evt.client_ip = clientIP;
349
+ this.enqueue(evt);
350
+ }
351
+ enqueue(evt) {
352
+ if (this.queue.length >= this.queueSize) {
353
+ this.logger.warn("kohi monitor queue is full; dropping event");
354
+ return;
355
+ }
356
+ this.queue.push(evt);
357
+ }
247
358
  startWorker() {
248
359
  const tick = async () => {
249
360
  let lastFlush = Date.now();
@@ -266,7 +377,7 @@ var Monitor = class _Monitor {
266
377
  tick().catch((err) => this.logger.error(`kohi: worker fatal: ${err}`));
267
378
  }
268
379
  async sendBatch(events) {
269
- const redacted = events.map(redactEvent);
380
+ const redacted = events.map((e) => "_type" in e && e._type === "error" ? e : redactEvent(e));
270
381
  let payload;
271
382
  try {
272
383
  payload = await gzipAsync(Buffer.from(JSON.stringify(redacted), "utf8"));
@@ -325,9 +436,24 @@ function expressMiddleware(monitor) {
325
436
  return;
326
437
  }
327
438
  const start = Date.now();
439
+ const reqURL = req.url ?? "/";
440
+ const reqMethod = (req.method ?? "GET").toUpperCase();
441
+ const reqHeaders = canonicalHeaders(req.headers);
442
+ const peerIP = peerIPFromRemoteAddr(req.socket?.remoteAddress ?? "");
443
+ const clientIP = extractClientIP(reqHeaders, peerIP);
328
444
  const chunks = [], resChunks = [];
329
- let reqBodyBuf = Buffer.alloc(0), totalRes = 0, capped = false, reqBodyReady = false, sent = false;
330
- req.on("data", (c) => chunks.push(c));
445
+ let reqBodyBuf = Buffer.alloc(0), totalReq = 0, totalRes = 0, capped = false, reqCapped = false, reqBodyReady = false, sent = false, errorCaptured = false;
446
+ req.on("data", (c) => {
447
+ if (reqCapped) return;
448
+ const rem = MAX_REQUEST_BODY - totalReq;
449
+ if (rem <= 0) {
450
+ reqCapped = true;
451
+ return;
452
+ }
453
+ chunks.push(c.subarray(0, Math.min(c.length, rem)));
454
+ totalReq += Math.min(c.length, rem);
455
+ if (c.length > rem) reqCapped = true;
456
+ });
331
457
  req.once("end", () => {
332
458
  reqBodyBuf = Buffer.concat(chunks);
333
459
  reqBodyReady = true;
@@ -356,10 +482,6 @@ function expressMiddleware(monitor) {
356
482
  const finalize = () => {
357
483
  if (sent) return;
358
484
  sent = true;
359
- const reqHeaders = canonicalHeaders(req.headers);
360
- reqHeaders["x-kohi-version"] = VERSION;
361
- const peerIP = peerIPFromRemoteAddr(req.socket?.remoteAddress ?? "");
362
- const clientIP = extractClientIP(reqHeaders, peerIP);
363
485
  const status = res.statusCode || 200;
364
486
  let resBody = parseJSONBody(Buffer.concat(resChunks));
365
487
  if (status >= 500 && resChunks.length === 0) resBody = { error: res.statusMessage || "Internal Server Error" };
@@ -377,6 +499,11 @@ function expressMiddleware(monitor) {
377
499
  client_ip: clientIP
378
500
  };
379
501
  monitor.addEvent(evt);
502
+ if (status >= 500 && !errorCaptured && !res._kohiErrorCaptured) {
503
+ const body = Buffer.concat(resChunks).toString("utf8").trim();
504
+ const msg = body.slice(0, 256) || res.statusMessage || "Internal Server Error";
505
+ monitor.captureHttpError(status, msg, reqURL, reqMethod, clientIP);
506
+ }
380
507
  };
381
508
  const fail = (err) => {
382
509
  if (sent) return;
@@ -388,18 +515,34 @@ function expressMiddleware(monitor) {
388
515
  res.on("finish", finalize);
389
516
  res.on("close", finalize);
390
517
  res.on("error", fail);
391
- try {
392
- next();
393
- } catch (err) {
394
- fail(err);
395
- throw err;
518
+ requestContext.run({ url: reqURL, method: reqMethod, clientIP }, () => {
519
+ try {
520
+ next();
521
+ } catch (err) {
522
+ errorCaptured = true;
523
+ const error = err instanceof Error ? err : new Error(String(err));
524
+ monitor.captureExceptionWithRequest(error, reqURL, reqMethod, clientIP);
525
+ fail(err);
526
+ throw err;
527
+ }
528
+ });
529
+ };
530
+ }
531
+ function expressErrorHandler(monitor) {
532
+ return (err, req, res, next) => {
533
+ if (monitor.isEnabled()) {
534
+ const error = err instanceof Error ? err : new Error(String(err));
535
+ const headers = canonicalHeaders(req.headers);
536
+ const ip = extractClientIP(headers, peerIPFromRemoteAddr(req.socket?.remoteAddress ?? ""));
537
+ monitor.captureExceptionWithRequest(error, req.url ?? "/", (req.method ?? "GET").toUpperCase(), ip);
538
+ res._kohiErrorCaptured = true;
396
539
  }
540
+ next(err);
397
541
  };
398
542
  }
399
543
 
400
544
  // src/integrations/nextjs.ts
401
545
  var http = __toESM(require("http"));
402
- var MAX_REQUEST_BODY = 64 * 1024;
403
546
  var sharedMonitor = null;
404
547
  var instrumented = false;
405
548
  var requestBodies = /* @__PURE__ */ new WeakMap();
@@ -435,7 +578,9 @@ function instrumentNextJs(cfg) {
435
578
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
436
579
  const remaining = MAX_REQUEST_BODY - bodyData.size;
437
580
  if (remaining > 0) {
438
- bodyData.chunks.push(buf.subarray(0, Math.min(buf.length, remaining)));
581
+ bodyData.chunks.push(
582
+ buf.subarray(0, Math.min(buf.length, remaining))
583
+ );
439
584
  bodyData.size += Math.min(buf.length, remaining);
440
585
  }
441
586
  }
@@ -454,12 +599,14 @@ function instrumentNextJs(cfg) {
454
599
  function instrumentRequest(req, res) {
455
600
  if (!sharedMonitor) return;
456
601
  const url = req.url ?? "/";
457
- if (url.startsWith("/_next") || url.startsWith("/__next") || url.includes("/_next/")) return;
602
+ if (url.startsWith("/_next") || url.startsWith("/__next") || url.includes("/_next/"))
603
+ return;
458
604
  if (!url.startsWith("/api/")) return;
459
605
  const start = Date.now();
460
606
  const method = req.method ?? "GET";
461
- const reqHeaders = canonicalHeaders(req.headers);
462
- reqHeaders["x-kohi-version"] = VERSION;
607
+ const reqHeaders = canonicalHeaders(
608
+ req.headers
609
+ );
463
610
  const peerIP = peerIPFromRemoteAddr(req.socket?.remoteAddress ?? "");
464
611
  const clientIP = extractClientIP(reqHeaders, peerIP);
465
612
  const captureState = { total: 0, capped: false };
@@ -485,7 +632,9 @@ function instrumentRequest(req, res) {
485
632
  status_code: res.statusCode,
486
633
  request_headers: reqHeaders,
487
634
  request_body: reqBody,
488
- response_headers: canonicalHeaders(res.getHeaders()),
635
+ response_headers: canonicalHeaders(
636
+ res.getHeaders()
637
+ ),
489
638
  response_body: parseJSONBody(resBody),
490
639
  duration_ms: duration,
491
640
  client_ip: clientIP
@@ -494,6 +643,15 @@ function instrumentRequest(req, res) {
494
643
  return originalEnd(chunk, ...args);
495
644
  };
496
645
  }
646
+ function onRequestError(err, request, _context) {
647
+ if (!sharedMonitor) return;
648
+ const error = err instanceof Error ? err : new Error(String(err));
649
+ const url = request?.path ?? "/";
650
+ const method = (request?.method ?? "GET").toUpperCase();
651
+ const headers = canonicalHeaders(request?.headers ?? {});
652
+ const clientIP = extractClientIP(headers, "");
653
+ sharedMonitor.captureExceptionWithRequest(error, url, method, clientIP);
654
+ }
497
655
 
498
656
  // src/index.ts
499
657
  function init(cfg) {
@@ -501,6 +659,7 @@ function init(cfg) {
501
659
  return {
502
660
  monitor,
503
661
  expressMiddleware: () => expressMiddleware(monitor),
662
+ expressErrorHandler: () => expressErrorHandler(monitor),
504
663
  instrumentNextJs: () => instrumentNextJs(cfg)
505
664
  };
506
665
  }
@@ -515,21 +674,24 @@ var index_default = defaultExport;
515
674
  Monitor,
516
675
  REDACTION_MASK,
517
676
  SENSITIVE_KEYS,
518
- VERSION,
519
677
  canonicalHeaders,
520
678
  createMonitor,
679
+ expressErrorHandler,
521
680
  expressMiddleware,
522
681
  extractClientIP,
523
682
  firstIPFromList,
524
683
  gzipAsync,
525
684
  init,
685
+ installGlobalHooks,
526
686
  instrumentNextJs,
527
687
  isRetryableStatus,
528
688
  isValidIP,
529
689
  jitter,
530
690
  normalizeIP,
691
+ onRequestError,
531
692
  parseForwardedHeader,
532
693
  parseJSONBody,
694
+ parseNodeStack,
533
695
  peerIPFromRemoteAddr,
534
696
  redactEvent,
535
697
  redactHeaders,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kohi-node",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Kohi API monitoring SDK for Node.js",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,8 +16,8 @@
16
16
  "README.md"
17
17
  ],
18
18
  "scripts": {
19
- "build": "rm -rf dist && esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js && dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check",
20
- "test": "npx tsx --test tests/index.test.ts",
19
+ "build": "rm -rf dist && esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js && dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check --export-referenced-types=false",
20
+ "test": "npx tsx --test tests/index.test.ts tests/errors.test.ts",
21
21
  "prepublishOnly": "npm run build"
22
22
  },
23
23
  "keywords": [