kohi-node 0.1.2
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 +21 -0
- package/README.md +41 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +540 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 KohiCorp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# kohi-node
|
|
2
|
+
|
|
3
|
+
Node.js SDK for Kohi.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install kohi-node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import kohi from "kohi-node";
|
|
15
|
+
|
|
16
|
+
const { expressMiddleware } = kohi.init({
|
|
17
|
+
projectKey: process.env.KOHI_PROJECT_KEY!,
|
|
18
|
+
secretKey: process.env.KOHI_SECRET_KEY!,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
app.use(expressMiddleware());
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For Next.js route handlers or the App Router bootstrap:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import kohi from "kohi-node";
|
|
28
|
+
|
|
29
|
+
kohi.init({
|
|
30
|
+
projectKey: process.env.KOHI_PROJECT_KEY!,
|
|
31
|
+
secretKey: process.env.KOHI_SECRET_KEY!,
|
|
32
|
+
}).instrumentNextJs();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Behavior
|
|
36
|
+
|
|
37
|
+
- Batches events in memory, default `batchSize=100`, `flushIntervalMs=5000`
|
|
38
|
+
- Retries retryable `408`, `429`, and `5xx` responses up to 3 times with backoff
|
|
39
|
+
- Caps the in-memory queue at `5000` events and drops new events when full
|
|
40
|
+
- Redacts common secret fields and masks email addresses before sending
|
|
41
|
+
- Adds `X-Project-Key` and HMAC-SHA256 `X-Signature` over the gzipped payload
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Generated by dts-bundle-generator v9.5.1
|
|
2
|
+
|
|
3
|
+
import { IncomingMessage, ServerResponse } from 'http';
|
|
4
|
+
import { gzip } from 'zlib';
|
|
5
|
+
|
|
6
|
+
export declare const VERSION = "node:0.1.2";
|
|
7
|
+
export declare const DEFAULT_INGEST_ENDPOINT = "https://kohicorp.com/api/ingest";
|
|
8
|
+
export declare const DEFAULT_MAX_CONCURRENT_SENDS = 5;
|
|
9
|
+
export declare const DEFAULT_QUEUE_SIZE = 5000;
|
|
10
|
+
export declare const DEFAULT_HTTP_TIMEOUT_MS = 10000;
|
|
11
|
+
export declare const SENSITIVE_KEYS: Set<string>;
|
|
12
|
+
export declare const REDACTION_MASK = "[REDACTED]";
|
|
13
|
+
export interface Config {
|
|
14
|
+
projectKey: string;
|
|
15
|
+
secretKey: string;
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
endpoint?: string;
|
|
18
|
+
maxConcurrentSends?: number;
|
|
19
|
+
queueSize?: number;
|
|
20
|
+
httpTimeoutMs?: number;
|
|
21
|
+
batchSize?: number;
|
|
22
|
+
flushIntervalMs?: number;
|
|
23
|
+
logger?: Logger;
|
|
24
|
+
}
|
|
25
|
+
export interface Logger {
|
|
26
|
+
info(msg: string): void;
|
|
27
|
+
warn(msg: string): void;
|
|
28
|
+
error(msg: string): void;
|
|
29
|
+
}
|
|
30
|
+
interface Event$1 {
|
|
31
|
+
url: string;
|
|
32
|
+
endpoint: string;
|
|
33
|
+
method: string;
|
|
34
|
+
status_code: number;
|
|
35
|
+
request_headers: Record<string, string>;
|
|
36
|
+
request_body: unknown;
|
|
37
|
+
response_headers: Record<string, string>;
|
|
38
|
+
response_body: unknown;
|
|
39
|
+
duration_ms: number;
|
|
40
|
+
client_ip?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare const gzipAsync: typeof gzip.__promisify__;
|
|
43
|
+
export declare const sign: (secret: Buffer, body: Buffer) => string;
|
|
44
|
+
export declare const jitter: (base: number) => number;
|
|
45
|
+
export declare const sleep: (ms: number) => Promise<void>;
|
|
46
|
+
export declare const isRetryableStatus: (s: number) => boolean;
|
|
47
|
+
export declare const normalizeIP: (ip: string) => string;
|
|
48
|
+
export declare function validateConfig(pk: string, sk: string): void;
|
|
49
|
+
export declare function canonicalHeaders(h: Record<string, string | string[] | number | undefined>): Record<string, string>;
|
|
50
|
+
export declare function redactValue(v: unknown): unknown;
|
|
51
|
+
export declare function redactHeaders(h: Record<string, string>): Record<string, string>;
|
|
52
|
+
export declare function redactEvent(e: Event$1): Event$1;
|
|
53
|
+
export declare function isValidIP(ip: string): boolean;
|
|
54
|
+
export declare function firstIPFromList(v: string): string;
|
|
55
|
+
export declare function parseForwardedHeader(v: string): string;
|
|
56
|
+
export declare function extractClientIP(h: Record<string, string>, peerIP: string): string;
|
|
57
|
+
export declare function peerIPFromRemoteAddr(addr: string): string;
|
|
58
|
+
export declare function parseJSONBody(raw: string | Buffer): unknown;
|
|
59
|
+
export declare class Monitor {
|
|
60
|
+
private readonly projectKey;
|
|
61
|
+
private readonly endpoint;
|
|
62
|
+
private readonly maxConcurrentSends;
|
|
63
|
+
private readonly queueSize;
|
|
64
|
+
private readonly httpTimeoutMs;
|
|
65
|
+
private readonly batchSize;
|
|
66
|
+
private readonly flushIntervalMs;
|
|
67
|
+
private readonly secret;
|
|
68
|
+
private readonly logger;
|
|
69
|
+
private readonly queue;
|
|
70
|
+
private enabled;
|
|
71
|
+
private closed;
|
|
72
|
+
private activeSends;
|
|
73
|
+
constructor(config: Config);
|
|
74
|
+
static noop(): Monitor;
|
|
75
|
+
isEnabled(): boolean;
|
|
76
|
+
addEvent(evt: Event$1): void;
|
|
77
|
+
shutdown(): Promise<void>;
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
private startWorker;
|
|
80
|
+
private sendBatch;
|
|
81
|
+
private sendBatchAttempt;
|
|
82
|
+
}
|
|
83
|
+
export declare function createMonitor(config: Config): Monitor;
|
|
84
|
+
export declare function expressMiddleware(monitor: Monitor): (req: IncomingMessage, res: ServerResponse, next: () => void) => void;
|
|
85
|
+
export declare function instrumentNextJs(cfg: Config): void;
|
|
86
|
+
export declare function init(cfg: Config): {
|
|
87
|
+
monitor: Monitor;
|
|
88
|
+
expressMiddleware: () => (req: import("http").IncomingMessage, res: import("http").ServerResponse, next: () => void) => void;
|
|
89
|
+
instrumentNextJs: () => void;
|
|
90
|
+
};
|
|
91
|
+
declare const defaultExport: typeof Monitor & {
|
|
92
|
+
init: typeof init;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
Event$1 as Event,
|
|
97
|
+
defaultExport as default,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
DEFAULT_HTTP_TIMEOUT_MS: () => DEFAULT_HTTP_TIMEOUT_MS,
|
|
34
|
+
DEFAULT_INGEST_ENDPOINT: () => DEFAULT_INGEST_ENDPOINT,
|
|
35
|
+
DEFAULT_MAX_CONCURRENT_SENDS: () => DEFAULT_MAX_CONCURRENT_SENDS,
|
|
36
|
+
DEFAULT_QUEUE_SIZE: () => DEFAULT_QUEUE_SIZE,
|
|
37
|
+
Monitor: () => Monitor,
|
|
38
|
+
REDACTION_MASK: () => REDACTION_MASK,
|
|
39
|
+
SENSITIVE_KEYS: () => SENSITIVE_KEYS,
|
|
40
|
+
VERSION: () => VERSION,
|
|
41
|
+
canonicalHeaders: () => canonicalHeaders,
|
|
42
|
+
createMonitor: () => createMonitor,
|
|
43
|
+
default: () => index_default,
|
|
44
|
+
expressMiddleware: () => expressMiddleware,
|
|
45
|
+
extractClientIP: () => extractClientIP,
|
|
46
|
+
firstIPFromList: () => firstIPFromList,
|
|
47
|
+
gzipAsync: () => gzipAsync,
|
|
48
|
+
init: () => init,
|
|
49
|
+
instrumentNextJs: () => instrumentNextJs,
|
|
50
|
+
isRetryableStatus: () => isRetryableStatus,
|
|
51
|
+
isValidIP: () => isValidIP,
|
|
52
|
+
jitter: () => jitter,
|
|
53
|
+
normalizeIP: () => normalizeIP,
|
|
54
|
+
parseForwardedHeader: () => parseForwardedHeader,
|
|
55
|
+
parseJSONBody: () => parseJSONBody,
|
|
56
|
+
peerIPFromRemoteAddr: () => peerIPFromRemoteAddr,
|
|
57
|
+
redactEvent: () => redactEvent,
|
|
58
|
+
redactHeaders: () => redactHeaders,
|
|
59
|
+
redactValue: () => redactValue,
|
|
60
|
+
sign: () => sign,
|
|
61
|
+
sleep: () => sleep,
|
|
62
|
+
validateConfig: () => validateConfig
|
|
63
|
+
});
|
|
64
|
+
module.exports = __toCommonJS(index_exports);
|
|
65
|
+
|
|
66
|
+
// src/core.ts
|
|
67
|
+
var import_crypto = require("crypto");
|
|
68
|
+
var import_zlib = require("zlib");
|
|
69
|
+
var import_util = require("util");
|
|
70
|
+
var VERSION = "node:0.1.2";
|
|
71
|
+
var DEFAULT_INGEST_ENDPOINT = "https://kohicorp.com/api/ingest";
|
|
72
|
+
var MAX_RESPONSE_BODY = 64 * 1024;
|
|
73
|
+
var DEFAULT_MAX_CONCURRENT_SENDS = 5;
|
|
74
|
+
var DEFAULT_QUEUE_SIZE = 5e3;
|
|
75
|
+
var DEFAULT_HTTP_TIMEOUT_MS = 1e4;
|
|
76
|
+
var MAX_ATTEMPTS = 3;
|
|
77
|
+
var BASE_BACKOFF_MS = 250;
|
|
78
|
+
var MAX_BACKOFF_MS = 2e3;
|
|
79
|
+
var PROJECT_KEY_PATTERN = /^pk_[A-Za-z0-9_-]{22}$/;
|
|
80
|
+
var SENSITIVE_KEYS = /* @__PURE__ */ new Set(["password", "secret", "token", "authorization", "api_key", "x-api-key", "apikey", "x-auth-token", "x-access-token", "bearer", "private_key", "private-key", "secret_key", "secret-key"]);
|
|
81
|
+
var EMAIL_KEYS = /* @__PURE__ */ new Set(["email", "email_address", "emailaddress"]);
|
|
82
|
+
var REDACTION_MASK = "[REDACTED]";
|
|
83
|
+
var IP_HEADERS = ["cf-connecting-ip", "x-vercel-forwarded-for", "x-forwarded-for", "x-real-ip", "x-cluster-client-ip", "fastly-client-ip"];
|
|
84
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
85
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
86
|
+
var noopLogger = { info: () => {
|
|
87
|
+
}, warn: () => {
|
|
88
|
+
}, error: () => {
|
|
89
|
+
} };
|
|
90
|
+
var gzipAsync = (0, import_util.promisify)(import_zlib.gzip);
|
|
91
|
+
var sign = (secret, body) => (0, import_crypto.createHmac)("sha256", secret).update(body).digest("hex");
|
|
92
|
+
var jitter = (base) => Math.floor(base * (0.8 + 0.4 * Math.random()));
|
|
93
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
94
|
+
var isRetryableStatus = (s) => s === 408 || s === 429 || s >= 500 && s < 600;
|
|
95
|
+
var normalizeIP = (ip) => ip.trim().replace(/^\[|\]$/g, "");
|
|
96
|
+
function validateConfig(pk, sk) {
|
|
97
|
+
if (!PROJECT_KEY_PATTERN.test(pk)) throw new Error("projectKey must start with 'pk_' followed by 22 base64url characters");
|
|
98
|
+
if (sk.length !== 43) throw new Error("secretKey must be exactly 43 base64url characters");
|
|
99
|
+
}
|
|
100
|
+
function canonicalHeaders(h) {
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const [k, v] of Object.entries(h)) if (v != null) out[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function maskEmail(e) {
|
|
106
|
+
if (typeof e !== "string") return REDACTION_MASK;
|
|
107
|
+
const at = e.indexOf("@");
|
|
108
|
+
if (at < 1) return REDACTION_MASK;
|
|
109
|
+
return e.slice(0, Math.min(2, at)) + "***" + e.slice(at);
|
|
110
|
+
}
|
|
111
|
+
function redactValue(v) {
|
|
112
|
+
if (v == null) return v;
|
|
113
|
+
if (Array.isArray(v)) return v.map(redactValue);
|
|
114
|
+
if (typeof v === "object") {
|
|
115
|
+
const out = {};
|
|
116
|
+
for (const [k, val] of Object.entries(v)) {
|
|
117
|
+
const key = k.toLowerCase();
|
|
118
|
+
out[k] = SENSITIVE_KEYS.has(key) ? REDACTION_MASK : EMAIL_KEYS.has(key) ? maskEmail(val) : redactValue(val);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
return v;
|
|
123
|
+
}
|
|
124
|
+
function redactHeaders(h) {
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const [k, v] of Object.entries(h)) out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? REDACTION_MASK : v;
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function redactEvent(e) {
|
|
130
|
+
return { ...e, request_headers: redactHeaders(e.request_headers), request_body: redactValue(e.request_body), response_headers: redactHeaders(e.response_headers), response_body: redactValue(e.response_body) };
|
|
131
|
+
}
|
|
132
|
+
function isValidIP(ip) {
|
|
133
|
+
if (!ip) return false;
|
|
134
|
+
const s = normalizeIP(ip);
|
|
135
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(s)) return s.split(".").every((p) => +p >= 0 && +p <= 255);
|
|
136
|
+
if (!/^[0-9a-fA-F:.]+$/.test(s) || !s.includes(":")) return false;
|
|
137
|
+
const parts = s.split("::");
|
|
138
|
+
if (parts.length > 2) return false;
|
|
139
|
+
return s.replace(/::/g, ":").split(":").filter(Boolean).length <= 8;
|
|
140
|
+
}
|
|
141
|
+
function firstIPFromList(v) {
|
|
142
|
+
for (const p of v.split(",")) {
|
|
143
|
+
const t = p.trim().replace(/"/g, "");
|
|
144
|
+
if (isValidIP(t)) return normalizeIP(t);
|
|
145
|
+
}
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
function parseForwardedHeader(v) {
|
|
149
|
+
for (const seg of v.split(";")) for (const item of seg.split(",")) {
|
|
150
|
+
if (!item.toLowerCase().includes("for=")) continue;
|
|
151
|
+
const parts = item.split("=");
|
|
152
|
+
if (parts.length === 2) {
|
|
153
|
+
const c = parts[1].trim().replace(/"/g, "");
|
|
154
|
+
if (isValidIP(c)) return normalizeIP(c);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
function extractClientIP(h, peerIP) {
|
|
160
|
+
for (const name of IP_HEADERS) {
|
|
161
|
+
const v = h[name];
|
|
162
|
+
if (v) {
|
|
163
|
+
const ip = firstIPFromList(v);
|
|
164
|
+
if (ip) return ip;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const fwd = h["forwarded"];
|
|
168
|
+
if (fwd) {
|
|
169
|
+
const ip = parseForwardedHeader(fwd);
|
|
170
|
+
if (ip) return ip;
|
|
171
|
+
}
|
|
172
|
+
return peerIP && isValidIP(peerIP) ? normalizeIP(peerIP) : "";
|
|
173
|
+
}
|
|
174
|
+
function peerIPFromRemoteAddr(addr) {
|
|
175
|
+
if (!addr) return "";
|
|
176
|
+
const m = addr.match(/^\[([^\]]+)\]:(\d+)$/);
|
|
177
|
+
if (m) return normalizeIP(m[1]);
|
|
178
|
+
const n = normalizeIP(addr);
|
|
179
|
+
if (isValidIP(n)) return n;
|
|
180
|
+
const i = addr.lastIndexOf(":");
|
|
181
|
+
return i !== -1 ? normalizeIP(addr.substring(0, i)) : n;
|
|
182
|
+
}
|
|
183
|
+
function parseJSONBody(raw) {
|
|
184
|
+
if (!raw || (Buffer.isBuffer(raw) ? raw.length === 0 : raw.length === 0)) return Buffer.isBuffer(raw) ? {} : null;
|
|
185
|
+
const str = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(str);
|
|
188
|
+
} catch {
|
|
189
|
+
return str;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/monitor.ts
|
|
194
|
+
var Monitor = class _Monitor {
|
|
195
|
+
projectKey;
|
|
196
|
+
endpoint;
|
|
197
|
+
maxConcurrentSends;
|
|
198
|
+
queueSize;
|
|
199
|
+
httpTimeoutMs;
|
|
200
|
+
batchSize;
|
|
201
|
+
flushIntervalMs;
|
|
202
|
+
secret;
|
|
203
|
+
logger;
|
|
204
|
+
queue = [];
|
|
205
|
+
enabled;
|
|
206
|
+
closed = false;
|
|
207
|
+
activeSends = 0;
|
|
208
|
+
constructor(config) {
|
|
209
|
+
const enabled = config.enabled ?? true;
|
|
210
|
+
if (enabled) validateConfig(config.projectKey, config.secretKey);
|
|
211
|
+
this.projectKey = config.projectKey;
|
|
212
|
+
this.endpoint = config.endpoint ?? DEFAULT_INGEST_ENDPOINT;
|
|
213
|
+
this.maxConcurrentSends = config.maxConcurrentSends ?? DEFAULT_MAX_CONCURRENT_SENDS;
|
|
214
|
+
this.queueSize = config.queueSize ?? DEFAULT_QUEUE_SIZE;
|
|
215
|
+
this.httpTimeoutMs = config.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
|
|
216
|
+
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
217
|
+
this.flushIntervalMs = config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
218
|
+
this.secret = Buffer.from(config.secretKey, "base64url");
|
|
219
|
+
this.logger = config.logger ?? noopLogger;
|
|
220
|
+
this.enabled = enabled;
|
|
221
|
+
if (this.enabled) this.startWorker();
|
|
222
|
+
}
|
|
223
|
+
static noop() {
|
|
224
|
+
return new _Monitor({ projectKey: "pk_00000000000000000000AA", secretKey: "0000000000000000000000000000000000000000000", enabled: false });
|
|
225
|
+
}
|
|
226
|
+
isEnabled() {
|
|
227
|
+
return this.enabled;
|
|
228
|
+
}
|
|
229
|
+
addEvent(evt) {
|
|
230
|
+
if (!this.enabled || this.closed) return;
|
|
231
|
+
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);
|
|
237
|
+
}
|
|
238
|
+
async shutdown() {
|
|
239
|
+
if (this.closed) return;
|
|
240
|
+
this.closed = true;
|
|
241
|
+
const deadline = Date.now() + 3e4;
|
|
242
|
+
while ((this.queue.length > 0 || this.activeSends > 0) && Date.now() < deadline) await sleep(50);
|
|
243
|
+
}
|
|
244
|
+
async close() {
|
|
245
|
+
return this.shutdown();
|
|
246
|
+
}
|
|
247
|
+
startWorker() {
|
|
248
|
+
const tick = async () => {
|
|
249
|
+
let lastFlush = Date.now();
|
|
250
|
+
while (true) {
|
|
251
|
+
if (this.closed && this.queue.length === 0 && this.activeSends === 0) break;
|
|
252
|
+
const elapsed = Date.now() - lastFlush;
|
|
253
|
+
const shouldFlush = this.queue.length >= this.batchSize || this.queue.length > 0 && elapsed >= this.flushIntervalMs;
|
|
254
|
+
if (shouldFlush && this.activeSends < this.maxConcurrentSends) {
|
|
255
|
+
const batch = this.queue.splice(0, this.batchSize);
|
|
256
|
+
if (batch.length > 0) {
|
|
257
|
+
lastFlush = Date.now();
|
|
258
|
+
this.activeSends++;
|
|
259
|
+
this.sendBatch(batch).catch((err) => this.logger.error(`kohi: batch send error: ${err}`)).finally(() => this.activeSends--);
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
await sleep(this.queue.length === 0 ? 100 : 10);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
tick().catch((err) => this.logger.error(`kohi: worker fatal: ${err}`));
|
|
267
|
+
}
|
|
268
|
+
async sendBatch(events) {
|
|
269
|
+
const redacted = events.map(redactEvent);
|
|
270
|
+
let payload;
|
|
271
|
+
try {
|
|
272
|
+
payload = await gzipAsync(Buffer.from(JSON.stringify(redacted), "utf8"));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
this.logger.error(`kohi: failed to encode batch: ${err}`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const signature = sign(this.secret, payload);
|
|
278
|
+
let backoff = BASE_BACKOFF_MS;
|
|
279
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
280
|
+
const result = await this.sendBatchAttempt(payload, signature, events.length, attempt);
|
|
281
|
+
if (result.done || !result.shouldRetry) return;
|
|
282
|
+
await sleep(jitter(backoff));
|
|
283
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async sendBatchAttempt(payload, signature, count, attempt) {
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
const timeoutId = setTimeout(() => controller.abort(), this.httpTimeoutMs);
|
|
289
|
+
const headers = {
|
|
290
|
+
"Content-Type": "application/json",
|
|
291
|
+
"Content-Encoding": "gzip",
|
|
292
|
+
"X-Project-Key": this.projectKey,
|
|
293
|
+
"X-Signature": signature,
|
|
294
|
+
"X-Batch-Count": String(count)
|
|
295
|
+
};
|
|
296
|
+
try {
|
|
297
|
+
const response = await fetch(this.endpoint, { method: "POST", headers, body: new Uint8Array(payload), signal: controller.signal });
|
|
298
|
+
clearTimeout(timeoutId);
|
|
299
|
+
if (response.status >= 200 && response.status < 300) return { done: true, shouldRetry: false };
|
|
300
|
+
if (!isRetryableStatus(response.status) || attempt === MAX_ATTEMPTS - 1) {
|
|
301
|
+
this.logger.warn(`kohi: dropping batch status=${response.status} count=${count} attempt=${attempt + 1}/${MAX_ATTEMPTS}`);
|
|
302
|
+
return { done: true, shouldRetry: false };
|
|
303
|
+
}
|
|
304
|
+
return { done: false, shouldRetry: true };
|
|
305
|
+
} catch (err) {
|
|
306
|
+
clearTimeout(timeoutId);
|
|
307
|
+
this.logger.error(`kohi: fetch error: ${err}`);
|
|
308
|
+
if (attempt === MAX_ATTEMPTS - 1) {
|
|
309
|
+
this.logger.warn(`kohi: dropping batch after network errors count=${count}`);
|
|
310
|
+
return { done: true, shouldRetry: false };
|
|
311
|
+
}
|
|
312
|
+
return { done: false, shouldRetry: true };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
function createMonitor(config) {
|
|
317
|
+
return new Monitor(config);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/integrations/express.ts
|
|
321
|
+
function expressMiddleware(monitor) {
|
|
322
|
+
return (req, res, next) => {
|
|
323
|
+
if (!monitor.isEnabled()) {
|
|
324
|
+
next();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const start = Date.now();
|
|
328
|
+
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));
|
|
331
|
+
req.once("end", () => {
|
|
332
|
+
reqBodyBuf = Buffer.concat(chunks);
|
|
333
|
+
reqBodyReady = true;
|
|
334
|
+
});
|
|
335
|
+
const origWrite = res.write.bind(res), origEnd = res.end.bind(res);
|
|
336
|
+
res.write = function(chunk, encOrCb, cb) {
|
|
337
|
+
if (chunk && !capped) {
|
|
338
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
339
|
+
const rem = MAX_RESPONSE_BODY - totalRes;
|
|
340
|
+
if (rem > 0) {
|
|
341
|
+
resChunks.push(buf.subarray(0, Math.min(buf.length, rem)));
|
|
342
|
+
totalRes += Math.min(buf.length, rem);
|
|
343
|
+
}
|
|
344
|
+
if (buf.length > rem) capped = true;
|
|
345
|
+
}
|
|
346
|
+
return typeof encOrCb === "function" ? origWrite(chunk, encOrCb) : origWrite(chunk, encOrCb, cb);
|
|
347
|
+
};
|
|
348
|
+
res.end = function(chunk, encOrCb, cb) {
|
|
349
|
+
if (chunk && !capped) {
|
|
350
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
351
|
+
const rem = MAX_RESPONSE_BODY - totalRes;
|
|
352
|
+
if (rem > 0) resChunks.push(buf.subarray(0, Math.min(buf.length, rem)));
|
|
353
|
+
}
|
|
354
|
+
return typeof encOrCb === "function" ? origEnd(chunk, encOrCb) : origEnd(chunk, encOrCb, cb);
|
|
355
|
+
};
|
|
356
|
+
const finalize = () => {
|
|
357
|
+
if (sent) return;
|
|
358
|
+
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
|
+
const status = res.statusCode || 200;
|
|
364
|
+
let resBody = parseJSONBody(Buffer.concat(resChunks));
|
|
365
|
+
if (status >= 500 && resChunks.length === 0) resBody = { error: res.statusMessage || "Internal Server Error" };
|
|
366
|
+
const finalReqBody = reqBodyReady ? reqBodyBuf : Buffer.concat(chunks);
|
|
367
|
+
const evt = {
|
|
368
|
+
url: req.url ?? "/",
|
|
369
|
+
endpoint: req.url ?? "/",
|
|
370
|
+
method: (req.method ?? "GET").toUpperCase(),
|
|
371
|
+
status_code: status,
|
|
372
|
+
request_headers: reqHeaders,
|
|
373
|
+
request_body: parseJSONBody(finalReqBody),
|
|
374
|
+
response_headers: canonicalHeaders(res.getHeaders()),
|
|
375
|
+
response_body: resBody,
|
|
376
|
+
duration_ms: Date.now() - start,
|
|
377
|
+
client_ip: clientIP
|
|
378
|
+
};
|
|
379
|
+
monitor.addEvent(evt);
|
|
380
|
+
};
|
|
381
|
+
const fail = (err) => {
|
|
382
|
+
if (sent) return;
|
|
383
|
+
res.statusCode = res.statusCode >= 400 ? res.statusCode : 500;
|
|
384
|
+
resChunks.length = 0;
|
|
385
|
+
resChunks.push(Buffer.from(String(err)));
|
|
386
|
+
finalize();
|
|
387
|
+
};
|
|
388
|
+
res.on("finish", finalize);
|
|
389
|
+
res.on("close", finalize);
|
|
390
|
+
res.on("error", fail);
|
|
391
|
+
try {
|
|
392
|
+
next();
|
|
393
|
+
} catch (err) {
|
|
394
|
+
fail(err);
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/integrations/nextjs.ts
|
|
401
|
+
var http = __toESM(require("http"));
|
|
402
|
+
var MAX_REQUEST_BODY = 64 * 1024;
|
|
403
|
+
var sharedMonitor = null;
|
|
404
|
+
var instrumented = false;
|
|
405
|
+
var requestBodies = /* @__PURE__ */ new WeakMap();
|
|
406
|
+
function appendChunkLimited(chunks, chunk, state, limit) {
|
|
407
|
+
if (state.capped || chunk == null) return;
|
|
408
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
409
|
+
const remaining = limit - state.total;
|
|
410
|
+
if (remaining <= 0) {
|
|
411
|
+
state.capped = true;
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const n = Math.min(buf.length, remaining);
|
|
415
|
+
chunks.push(buf.subarray(0, n));
|
|
416
|
+
state.total += n;
|
|
417
|
+
if (buf.length > remaining) state.capped = true;
|
|
418
|
+
}
|
|
419
|
+
function instrumentNextJs(cfg) {
|
|
420
|
+
if (instrumented) return;
|
|
421
|
+
instrumented = true;
|
|
422
|
+
const enabled = cfg.enabled ?? true;
|
|
423
|
+
if (!enabled) return;
|
|
424
|
+
sharedMonitor = new Monitor(cfg);
|
|
425
|
+
const originalPush = http.IncomingMessage.prototype.push;
|
|
426
|
+
http.IncomingMessage.prototype.push = function(chunk, encoding) {
|
|
427
|
+
const url = this.url ?? "";
|
|
428
|
+
if (url.startsWith("/api/") && !url.startsWith("/_next") && chunk !== null) {
|
|
429
|
+
let bodyData = requestBodies.get(this);
|
|
430
|
+
if (!bodyData) {
|
|
431
|
+
bodyData = { chunks: [], size: 0 };
|
|
432
|
+
requestBodies.set(this, bodyData);
|
|
433
|
+
}
|
|
434
|
+
if (bodyData.size < MAX_REQUEST_BODY) {
|
|
435
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
436
|
+
const remaining = MAX_REQUEST_BODY - bodyData.size;
|
|
437
|
+
if (remaining > 0) {
|
|
438
|
+
bodyData.chunks.push(buf.subarray(0, Math.min(buf.length, remaining)));
|
|
439
|
+
bodyData.size += Math.min(buf.length, remaining);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return originalPush.call(this, chunk, encoding);
|
|
444
|
+
};
|
|
445
|
+
const originalEmit = http.Server.prototype.emit;
|
|
446
|
+
http.Server.prototype.emit = function(event, ...args) {
|
|
447
|
+
if (event === "request") {
|
|
448
|
+
const [req, res] = args;
|
|
449
|
+
instrumentRequest(req, res);
|
|
450
|
+
}
|
|
451
|
+
return originalEmit.apply(this, [event, ...args]);
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function instrumentRequest(req, res) {
|
|
455
|
+
if (!sharedMonitor) return;
|
|
456
|
+
const url = req.url ?? "/";
|
|
457
|
+
if (url.startsWith("/_next") || url.startsWith("/__next") || url.includes("/_next/")) return;
|
|
458
|
+
if (!url.startsWith("/api/")) return;
|
|
459
|
+
const start = Date.now();
|
|
460
|
+
const method = req.method ?? "GET";
|
|
461
|
+
const reqHeaders = canonicalHeaders(req.headers);
|
|
462
|
+
reqHeaders["x-kohi-version"] = VERSION;
|
|
463
|
+
const peerIP = peerIPFromRemoteAddr(req.socket?.remoteAddress ?? "");
|
|
464
|
+
const clientIP = extractClientIP(reqHeaders, peerIP);
|
|
465
|
+
const captureState = { total: 0, capped: false };
|
|
466
|
+
const resChunks = [];
|
|
467
|
+
const originalWrite = res.write.bind(res);
|
|
468
|
+
const originalEnd = res.end.bind(res);
|
|
469
|
+
res.write = function(chunk, ...args) {
|
|
470
|
+
appendChunkLimited(resChunks, chunk, captureState, MAX_RESPONSE_BODY);
|
|
471
|
+
return originalWrite(chunk, ...args);
|
|
472
|
+
};
|
|
473
|
+
res.end = function(chunk, ...args) {
|
|
474
|
+
appendChunkLimited(resChunks, chunk, captureState, MAX_RESPONSE_BODY);
|
|
475
|
+
const resBody = Buffer.concat(resChunks).toString("utf8");
|
|
476
|
+
const duration = Date.now() - start;
|
|
477
|
+
const bodyData = requestBodies.get(req);
|
|
478
|
+
const reqBodyBuf = bodyData ? Buffer.concat(bodyData.chunks) : Buffer.alloc(0);
|
|
479
|
+
const reqBody = reqBodyBuf.length > 0 ? parseJSONBody(reqBodyBuf) : {};
|
|
480
|
+
requestBodies.delete(req);
|
|
481
|
+
const evt = {
|
|
482
|
+
url,
|
|
483
|
+
endpoint: url,
|
|
484
|
+
method,
|
|
485
|
+
status_code: res.statusCode,
|
|
486
|
+
request_headers: reqHeaders,
|
|
487
|
+
request_body: reqBody,
|
|
488
|
+
response_headers: canonicalHeaders(res.getHeaders()),
|
|
489
|
+
response_body: parseJSONBody(resBody),
|
|
490
|
+
duration_ms: duration,
|
|
491
|
+
client_ip: clientIP
|
|
492
|
+
};
|
|
493
|
+
sharedMonitor.addEvent(evt);
|
|
494
|
+
return originalEnd(chunk, ...args);
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/index.ts
|
|
499
|
+
function init(cfg) {
|
|
500
|
+
const monitor = new Monitor(cfg);
|
|
501
|
+
return {
|
|
502
|
+
monitor,
|
|
503
|
+
expressMiddleware: () => expressMiddleware(monitor),
|
|
504
|
+
instrumentNextJs: () => instrumentNextJs(cfg)
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
var defaultExport = Object.assign(Monitor, { init });
|
|
508
|
+
var index_default = defaultExport;
|
|
509
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
510
|
+
0 && (module.exports = {
|
|
511
|
+
DEFAULT_HTTP_TIMEOUT_MS,
|
|
512
|
+
DEFAULT_INGEST_ENDPOINT,
|
|
513
|
+
DEFAULT_MAX_CONCURRENT_SENDS,
|
|
514
|
+
DEFAULT_QUEUE_SIZE,
|
|
515
|
+
Monitor,
|
|
516
|
+
REDACTION_MASK,
|
|
517
|
+
SENSITIVE_KEYS,
|
|
518
|
+
VERSION,
|
|
519
|
+
canonicalHeaders,
|
|
520
|
+
createMonitor,
|
|
521
|
+
expressMiddleware,
|
|
522
|
+
extractClientIP,
|
|
523
|
+
firstIPFromList,
|
|
524
|
+
gzipAsync,
|
|
525
|
+
init,
|
|
526
|
+
instrumentNextJs,
|
|
527
|
+
isRetryableStatus,
|
|
528
|
+
isValidIP,
|
|
529
|
+
jitter,
|
|
530
|
+
normalizeIP,
|
|
531
|
+
parseForwardedHeader,
|
|
532
|
+
parseJSONBody,
|
|
533
|
+
peerIPFromRemoteAddr,
|
|
534
|
+
redactEvent,
|
|
535
|
+
redactHeaders,
|
|
536
|
+
redactValue,
|
|
537
|
+
sign,
|
|
538
|
+
sleep,
|
|
539
|
+
validateConfig
|
|
540
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kohi-node",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Kohi API monitoring SDK for Node.js",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
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",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"api",
|
|
25
|
+
"monitoring",
|
|
26
|
+
"observability",
|
|
27
|
+
"kohi"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/kohicorp/kohi",
|
|
33
|
+
"directory": "sdk/node"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://kohicorp.com",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/kohicorp/kohi/issues"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.19.30",
|
|
41
|
+
"dts-bundle-generator": "^9.5.1",
|
|
42
|
+
"esbuild": "^0.27.2",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|