@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 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
+ [![npm version](https://img.shields.io/npm/v/@webimpian/log-central-node.svg)](https://www.npmjs.com/package/@webimpian/log-central-node)
4
+ [![License](https://img.shields.io/npm/l/@webimpian/log-central-node.svg)](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
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const { LogCentralClient, formatTimestamp, safeStringify, LEVELS } = require('./client');
4
+
5
+ module.exports = { LogCentralClient, formatTimestamp, safeStringify, LEVELS };
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
+ }