@webimpian/log-central-node 1.0.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.md +21 -0
- package/README.md +75 -0
- package/lib/client.js +277 -0
- package/lib/index.js +5 -0
- package/package.json +31 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Webimpian
|
|
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,75 @@
|
|
|
1
|
+
# Log Central for Node.js
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@webimpian/log-central-node)
|
|
4
|
+
[](LICENSE.md)
|
|
5
|
+
|
|
6
|
+
Ship logs and errors from any Node.js application to a [Log Central](https://log.dev-aplikasiniaga.com) server.
|
|
7
|
+
|
|
8
|
+
- **General-purpose client** — leveled logging (`debug` … `emergency`) and exception capture from any Node.js 18+ process. Zero dependencies.
|
|
9
|
+
- **Non-blocking by design** — entries are batched in memory and flushed in the background with capped exponential backoff. Delivery failures are silent and bounded; logging can never crash or slow the host application.
|
|
10
|
+
- **ZoneMTA?** — use [`@webimpian/zonemta-log-central`](https://www.npmjs.com/package/@webimpian/zonemta-log-central), a ZoneMTA plugin built on this client.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @webimpian/log-central-node
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Node.js 18+ (uses global `fetch`) and a Log Central project key — create an app on the [dashboard](https://log.dev-aplikasiniaga.com) to obtain one.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
const { LogCentralClient } = require('@webimpian/log-central-node');
|
|
24
|
+
|
|
25
|
+
const logs = new LogCentralClient({
|
|
26
|
+
url: 'https://logs.example.com/api',
|
|
27
|
+
token: 'your-project-key',
|
|
28
|
+
app: 'your-app-slug',
|
|
29
|
+
environment: 'production',
|
|
30
|
+
channel: 'app',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Leveled logging (debug, info, notice, warning, error, critical, alert, emergency)
|
|
34
|
+
logs.info('Callback received', { ref: 'BC-8231' });
|
|
35
|
+
logs.warning('Retrying webhook', { attempt: 3 }, { channel: 'webhook', traceId: 'tr-91' });
|
|
36
|
+
|
|
37
|
+
// Exception capture (fingerprinted, grouped by the error tracker)
|
|
38
|
+
try {
|
|
39
|
+
await processPayment(order);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
logs.captureException(err, { entrypoint: 'queue', context: { orderId: order.id } });
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Optional: flush before shutdown
|
|
46
|
+
process.on('SIGTERM', () => logs.close());
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Options
|
|
50
|
+
|
|
51
|
+
| Option | Default | Purpose |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `url` | *(required)* | Log Central ingest base URL, e.g. `https://logs.example.com/api` |
|
|
54
|
+
| `token` | *(required)* | The app's project key (bearer token) |
|
|
55
|
+
| `app` | *(required)* | The app's slug, must match the token's app |
|
|
56
|
+
| `environment` | `production` | Environment tag on every entry |
|
|
57
|
+
| `channel` | `app` | Default log channel |
|
|
58
|
+
| `hostname` | `os.hostname()` | Hostname tag on every entry |
|
|
59
|
+
| `batchSize` | `200` | Entries per POST (hard-capped at the server's 1,000) |
|
|
60
|
+
| `flushInterval` | `5000` | Background flush period in ms |
|
|
61
|
+
| `maxQueue` | `10000` | Max buffered entries per type; oldest are dropped beyond this |
|
|
62
|
+
| `requestTimeout` | `10000` | Per-request timeout in ms |
|
|
63
|
+
| `onError` | `null` | Callback invoked with delivery/rejection errors (for debugging) |
|
|
64
|
+
|
|
65
|
+
Delivery semantics: batches that fail with a network error or 5xx stay queued and retry with capped backoff (max 60s); batches rejected with a 4xx are dropped (retrying cannot fix them) and reported to `onError`.
|
|
66
|
+
|
|
67
|
+
## Testing
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm test
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT — see [LICENSE.md](LICENSE.md).
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
const LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
|
|
7
|
+
|
|
8
|
+
const LEVEL_ALIASES = {
|
|
9
|
+
warn: 'warning',
|
|
10
|
+
err: 'error',
|
|
11
|
+
fatal: 'critical',
|
|
12
|
+
trace: 'debug',
|
|
13
|
+
verbose: 'debug',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ENTRYPOINTS = ['http', 'console', 'queue'];
|
|
17
|
+
|
|
18
|
+
const MAX_BATCH = 1000;
|
|
19
|
+
|
|
20
|
+
function formatTimestamp(date = new Date()) {
|
|
21
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0');
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` +
|
|
25
|
+
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.` +
|
|
26
|
+
pad(date.getUTCMilliseconds(), 3)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeStringify(value) {
|
|
31
|
+
const seen = new WeakSet();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const json = JSON.stringify(value ?? {}, (key, entry) => {
|
|
35
|
+
if (typeof entry === 'bigint') {
|
|
36
|
+
return entry.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof entry === 'object' && entry !== null) {
|
|
40
|
+
if (seen.has(entry)) {
|
|
41
|
+
return '[circular]';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
seen.add(entry);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return entry;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return json === undefined ? '{}' : json;
|
|
51
|
+
} catch {
|
|
52
|
+
return '{}';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function md5(value) {
|
|
57
|
+
return crypto.createHash('md5').update(value).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function firstStackFrame(stack) {
|
|
61
|
+
for (const line of String(stack).split('\n').slice(1)) {
|
|
62
|
+
const match = /^\s*at\s+(?:.+?\s+\()?(.+?):(\d+):\d+\)?\s*$/.exec(line);
|
|
63
|
+
|
|
64
|
+
if (match) {
|
|
65
|
+
return { file: match[1], line: Number(match[2]) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { file: '', line: 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class LogCentralClient {
|
|
73
|
+
constructor(options = {}) {
|
|
74
|
+
for (const required of ['url', 'token', 'app']) {
|
|
75
|
+
if (!options[required] || typeof options[required] !== 'string') {
|
|
76
|
+
throw new TypeError(`LogCentralClient: option '${required}' is required`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.url = options.url.replace(/\/+$/, '');
|
|
81
|
+
this.token = options.token;
|
|
82
|
+
this.app = options.app;
|
|
83
|
+
this.environment = options.environment || 'production';
|
|
84
|
+
this.channel = options.channel || 'app';
|
|
85
|
+
this.hostname = options.hostname || os.hostname();
|
|
86
|
+
this.batchSize = Math.min(options.batchSize || 200, MAX_BATCH);
|
|
87
|
+
this.flushInterval = options.flushInterval || 5000;
|
|
88
|
+
this.maxQueue = options.maxQueue || 10000;
|
|
89
|
+
this.requestTimeout = options.requestTimeout || 10000;
|
|
90
|
+
this.onError = typeof options.onError === 'function' ? options.onError : null;
|
|
91
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
92
|
+
|
|
93
|
+
this.queues = { logs: [], errors: [], requests: [] };
|
|
94
|
+
this.dropped = 0;
|
|
95
|
+
this.failures = 0;
|
|
96
|
+
this.retryAt = 0;
|
|
97
|
+
this.flushing = null;
|
|
98
|
+
|
|
99
|
+
this.timer = setInterval(() => {
|
|
100
|
+
this.flush().catch(() => {});
|
|
101
|
+
}, this.flushInterval);
|
|
102
|
+
|
|
103
|
+
if (typeof this.timer.unref === 'function') {
|
|
104
|
+
this.timer.unref();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
log(level, message, context = {}, overrides = {}) {
|
|
109
|
+
let normalized = String(level).toLowerCase();
|
|
110
|
+
normalized = LEVEL_ALIASES[normalized] || normalized;
|
|
111
|
+
|
|
112
|
+
if (!LEVELS.includes(normalized)) {
|
|
113
|
+
normalized = 'info';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const entry = {
|
|
117
|
+
timestamp: overrides.timestamp || formatTimestamp(),
|
|
118
|
+
app: this.app,
|
|
119
|
+
environment: overrides.environment || this.environment,
|
|
120
|
+
channel: overrides.channel || this.channel,
|
|
121
|
+
level: normalized,
|
|
122
|
+
message: String(message ?? ''),
|
|
123
|
+
context: safeStringify(context),
|
|
124
|
+
hostname: overrides.hostname || this.hostname,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (overrides.traceId) {
|
|
128
|
+
entry.trace_id = String(overrides.traceId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.enqueue('logs', entry);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
captureException(err, extra = {}) {
|
|
135
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
136
|
+
const stack = error.stack || '';
|
|
137
|
+
const frame = firstStackFrame(stack);
|
|
138
|
+
const name = error.name || 'Error';
|
|
139
|
+
|
|
140
|
+
const entry = {
|
|
141
|
+
timestamp: extra.timestamp || formatTimestamp(),
|
|
142
|
+
fingerprint: md5(`${name}|${frame.file}|${frame.line}`),
|
|
143
|
+
app: this.app,
|
|
144
|
+
environment: extra.environment || this.environment,
|
|
145
|
+
exception: name,
|
|
146
|
+
message: String(error.message ?? ''),
|
|
147
|
+
file: frame.file,
|
|
148
|
+
line: frame.line,
|
|
149
|
+
trace: stack,
|
|
150
|
+
url: extra.url || '',
|
|
151
|
+
method: extra.method || 'CLI',
|
|
152
|
+
user_id: Number.isInteger(extra.userId) && extra.userId >= 0 ? extra.userId : 0,
|
|
153
|
+
input: safeStringify(extra.context ?? {}),
|
|
154
|
+
hostname: extra.hostname || this.hostname,
|
|
155
|
+
entrypoint: ENTRYPOINTS.includes(extra.entrypoint) ? extra.entrypoint : 'console',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (extra.ip) {
|
|
159
|
+
entry.ip = String(extra.ip);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (extra.userAgent) {
|
|
163
|
+
entry.user_agent = String(extra.userAgent);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (extra.user) {
|
|
167
|
+
entry.user = safeStringify(extra.user);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.enqueue('errors', entry);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
enqueue(type, entry) {
|
|
174
|
+
const queue = this.queues[type];
|
|
175
|
+
|
|
176
|
+
if (!queue) {
|
|
177
|
+
throw new TypeError(`LogCentralClient: unknown entry type '${type}'`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (queue.length >= this.maxQueue) {
|
|
181
|
+
queue.shift();
|
|
182
|
+
this.dropped++;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
queue.push(entry);
|
|
186
|
+
|
|
187
|
+
if (queue.length >= this.batchSize) {
|
|
188
|
+
this.flush().catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async flush() {
|
|
193
|
+
if (this.flushing) {
|
|
194
|
+
return this.flushing;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (Date.now() < this.retryAt) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.flushing = this.#drain().finally(() => {
|
|
202
|
+
this.flushing = null;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return this.flushing;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async close() {
|
|
209
|
+
clearInterval(this.timer);
|
|
210
|
+
this.retryAt = 0;
|
|
211
|
+
await this.flush();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async #drain() {
|
|
215
|
+
for (const type of Object.keys(this.queues)) {
|
|
216
|
+
const queue = this.queues[type];
|
|
217
|
+
|
|
218
|
+
while (queue.length > 0) {
|
|
219
|
+
const batch = queue.slice(0, this.batchSize);
|
|
220
|
+
let response;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
response = await this.fetch(`${this.url}/${type}`, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: {
|
|
226
|
+
authorization: `Bearer ${this.token}`,
|
|
227
|
+
'content-type': 'application/json',
|
|
228
|
+
},
|
|
229
|
+
body: JSON.stringify(batch),
|
|
230
|
+
signal: AbortSignal.timeout(this.requestTimeout),
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
this.#backoff(err);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (response.status >= 500) {
|
|
238
|
+
this.#backoff(new Error(`Log Central responded ${response.status}`));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
queue.splice(0, batch.length);
|
|
243
|
+
this.failures = 0;
|
|
244
|
+
this.retryAt = 0;
|
|
245
|
+
|
|
246
|
+
if (response.status >= 400) {
|
|
247
|
+
const body = await response.text().catch(() => '');
|
|
248
|
+
this.#report(new Error(`Log Central rejected a ${type} batch (${response.status}): ${body.slice(0, 200)}`));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#backoff(err) {
|
|
255
|
+
this.failures++;
|
|
256
|
+
this.retryAt = Date.now() + Math.min(2 ** this.failures * 1000, 60000);
|
|
257
|
+
this.#report(err);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#report(err) {
|
|
261
|
+
if (this.onError) {
|
|
262
|
+
try {
|
|
263
|
+
this.onError(err);
|
|
264
|
+
} catch {
|
|
265
|
+
// onError must never take the client down
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const level of LEVELS) {
|
|
272
|
+
LogCentralClient.prototype[level] = function (message, context = {}, overrides = {}) {
|
|
273
|
+
this.log(level, message, context, overrides);
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { LogCentralClient, formatTimestamp, safeStringify, LEVELS, MAX_BATCH };
|
package/lib/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webimpian/log-central-node",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Ship logs and errors from Node.js applications to a Log Central server.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"logging",
|
|
7
|
+
"error-tracking",
|
|
8
|
+
"log-central"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Khairul Imran <infrastructure@webimpian.com>",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/webimpian-os/log-central-node.git"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./lib/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib",
|
|
25
|
+
"LICENSE.md",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test"
|
|
30
|
+
}
|
|
31
|
+
}
|