@m1kad0/hannah-logging 0.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/README.md +99 -0
- package/dist/buffer.d.ts +40 -0
- package/dist/buffer.js +94 -0
- package/dist/hooks.d.ts +15 -0
- package/dist/hooks.js +61 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +22 -0
- package/dist/secrets.d.ts +14 -0
- package/dist/secrets.js +70 -0
- package/dist/shipper.d.ts +97 -0
- package/dist/shipper.js +422 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @m1kad0/hannah-logging (TypeScript)
|
|
2
|
+
|
|
3
|
+
Ships the logs of a Hannah component to the Hannah log collector, so a user can download
|
|
4
|
+
one archive of all components' logs from the WebUI.
|
|
5
|
+
|
|
6
|
+
It works with whatever the component logs with. The component's own output stays exactly
|
|
7
|
+
as it is; the library additionally buffers a copy for the collector.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @m1kad0/hannah-logging
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`@m1kad0/hannah-proto` and `@grpc/grpc-js` are peer dependencies, so the library uses the
|
|
16
|
+
component's own copies. Every call to Hannah Core has to carry the protocol version of the
|
|
17
|
+
`hannah-proto` the component uses.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Create the shipper as early as possible; everything from then on is buffered. Connect it
|
|
22
|
+
once the config is loaded.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { LogShipper, hookConsole, wrapLogger } from '@m1kad0/hannah-logging';
|
|
26
|
+
|
|
27
|
+
const shipper = new LogShipper({
|
|
28
|
+
component: 'msteams',
|
|
29
|
+
version,
|
|
30
|
+
secrets: [config.botPassword], // masked wherever they appear
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Pick the way in that fits the component:
|
|
34
|
+
hookConsole(shipper); // console.log/info/warn/error/debug
|
|
35
|
+
const log = wrapLogger(this.log, shipper); // ioBroker's this.log, or anything with debug/info/warn/error
|
|
36
|
+
myLogger.onLine(shipper.hook); // any other logger: hook(level, message, meta?)
|
|
37
|
+
|
|
38
|
+
// Once the config is loaded:
|
|
39
|
+
shipper.connect({ hannahAddress: 'localhost:50051' }); // optional: collectorAddress as fallback
|
|
40
|
+
// On shutdown:
|
|
41
|
+
await shipper.close(); // sends what is still buffered (up to 2 s)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### The hook
|
|
45
|
+
|
|
46
|
+
`shipper.hook(level, message, meta?)` takes one log line:
|
|
47
|
+
|
|
48
|
+
- `level`: `'silly' | 'trace' | 'debug' | 'info' | 'log' | 'warn' | 'warning' | 'error' | 'fatal' | 'critical'`
|
|
49
|
+
- `meta.logger`: module or subsystem name, used for `loggerCategories`
|
|
50
|
+
- `meta.category`: `Category.GENERAL`, `Category.TRANSCRIPT` or `Category.METADATA`
|
|
51
|
+
- `meta.timestamp`: Unix ms, defaults to now
|
|
52
|
+
|
|
53
|
+
It is a bound function, so it can be handed to a pino hook or a winston transport as it is.
|
|
54
|
+
|
|
55
|
+
### wrapLogger and hookConsole
|
|
56
|
+
|
|
57
|
+
- `wrapLogger(target, shipper, meta?)` returns an object with the same interface as
|
|
58
|
+
`target`. Level method calls go to `target` unchanged and are shipped as well.
|
|
59
|
+
Everything else (for example ioBroker's `log.level`) passes through.
|
|
60
|
+
- `hookConsole(shipper, meta?)` replaces `console.debug/info/log/warn/error` with versions
|
|
61
|
+
that print as before and ship as well. Arguments are formatted like Node does, so an
|
|
62
|
+
`Error` keeps its stack trace. It returns a function that restores the original console.
|
|
63
|
+
|
|
64
|
+
Both report the library's own connection problems to the unwrapped logger or the original
|
|
65
|
+
console. Those messages are never shipped.
|
|
66
|
+
|
|
67
|
+
### Categories
|
|
68
|
+
|
|
69
|
+
Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
|
|
70
|
+
`METADATA` (room and device names, presence). Exports can leave out the last two, and the
|
|
71
|
+
WebUI does so by default. Tag by logger name with
|
|
72
|
+
`loggerCategories: { stt: Category.TRANSCRIPT }` (which also covers `stt.whisper`), or per
|
|
73
|
+
line with `meta.category`.
|
|
74
|
+
|
|
75
|
+
### Secrets
|
|
76
|
+
|
|
77
|
+
Before a line leaves the process, the library masks:
|
|
78
|
+
|
|
79
|
+
- values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
|
|
80
|
+
- `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
|
|
81
|
+
- Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
|
|
82
|
+
- every value in `secrets` or added later with `shipper.addSecret(…)`
|
|
83
|
+
|
|
84
|
+
Extra regexes go in `secretPatterns`. The component's own output still shows the unmasked
|
|
85
|
+
line.
|
|
86
|
+
|
|
87
|
+
## Behaviour
|
|
88
|
+
|
|
89
|
+
- **Buffer:** 4 MiB by default (`maxBufferBytes`). When it is full, the oldest lines are
|
|
90
|
+
dropped and reported to the collector as a gap. While no collector is known, the buffer
|
|
91
|
+
simply keeps running as a ring.
|
|
92
|
+
- **Timestamps** are taken when a line is logged, not when it is sent.
|
|
93
|
+
- **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
|
|
94
|
+
log collector when it moves. If Core is unreachable, the last known collector is kept.
|
|
95
|
+
The static `collectorAddress` is used while none is announced.
|
|
96
|
+
- **Never blocks the component:** logging only appends to the buffer. Sending is
|
|
97
|
+
asynchronous, connection problems are reported once and retried with backoff.
|
|
98
|
+
- **Process exit:** an open connection keeps Node's event loop alive. Call
|
|
99
|
+
`shipper.close()` on shutdown.
|
package/dist/buffer.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Bounded in-memory buffer between the hook and the shipper. */
|
|
2
|
+
export interface Entry {
|
|
3
|
+
timestampMs: number;
|
|
4
|
+
level: number;
|
|
5
|
+
logger: string;
|
|
6
|
+
message: string;
|
|
7
|
+
category: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Gap {
|
|
10
|
+
dropped: number;
|
|
11
|
+
fromMs: number;
|
|
12
|
+
toMs: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Ring buffer limited by size. When full, the oldest entries are dropped and recorded
|
|
16
|
+
* as a gap, which is shipped before the remaining entries.
|
|
17
|
+
*/
|
|
18
|
+
export declare class LogBuffer {
|
|
19
|
+
private readonly maxBytes;
|
|
20
|
+
private entries;
|
|
21
|
+
private head;
|
|
22
|
+
private bytes;
|
|
23
|
+
private gap;
|
|
24
|
+
private waiters;
|
|
25
|
+
constructor(maxBytes: number);
|
|
26
|
+
put(entry: Entry): void;
|
|
27
|
+
private dropOldest;
|
|
28
|
+
/** Removes and returns the pending gap (if any) and up to `maxItems` entries, oldest first. */
|
|
29
|
+
take(maxItems: number): {
|
|
30
|
+
gap: Gap | null;
|
|
31
|
+
entries: Entry[];
|
|
32
|
+
};
|
|
33
|
+
/** Resolves when data arrives, on `wake()`, or after `timeoutMs`. */
|
|
34
|
+
wait(timeoutMs: number): Promise<void>;
|
|
35
|
+
/** Wakes a pending `wait`, e.g. to react to a new collector address or shutdown. */
|
|
36
|
+
wake(): void;
|
|
37
|
+
get length(): number;
|
|
38
|
+
get empty(): boolean;
|
|
39
|
+
private compact;
|
|
40
|
+
}
|
package/dist/buffer.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Bounded in-memory buffer between the hook and the shipper. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.LogBuffer = void 0;
|
|
5
|
+
// Rough per-entry overhead on top of message + logger name, so a flood of empty
|
|
6
|
+
// messages still counts against the limit.
|
|
7
|
+
const ENTRY_OVERHEAD = 64;
|
|
8
|
+
function size(e) {
|
|
9
|
+
return e.message.length + e.logger.length + ENTRY_OVERHEAD;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Ring buffer limited by size. When full, the oldest entries are dropped and recorded
|
|
13
|
+
* as a gap, which is shipped before the remaining entries.
|
|
14
|
+
*/
|
|
15
|
+
class LogBuffer {
|
|
16
|
+
maxBytes;
|
|
17
|
+
entries = [];
|
|
18
|
+
head = 0; // index of the oldest entry; compacted lazily
|
|
19
|
+
bytes = 0;
|
|
20
|
+
gap = null;
|
|
21
|
+
waiters = [];
|
|
22
|
+
constructor(maxBytes) {
|
|
23
|
+
this.maxBytes = maxBytes;
|
|
24
|
+
}
|
|
25
|
+
put(entry) {
|
|
26
|
+
this.entries.push(entry);
|
|
27
|
+
this.bytes += size(entry);
|
|
28
|
+
while (this.bytes > this.maxBytes && this.length > 0) {
|
|
29
|
+
this.dropOldest();
|
|
30
|
+
}
|
|
31
|
+
this.wake();
|
|
32
|
+
}
|
|
33
|
+
dropOldest() {
|
|
34
|
+
const old = this.entries[this.head++];
|
|
35
|
+
this.bytes -= size(old);
|
|
36
|
+
if (this.gap === null) {
|
|
37
|
+
this.gap = { dropped: 1, fromMs: old.timestampMs, toMs: old.timestampMs };
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
this.gap.dropped++;
|
|
41
|
+
this.gap.toMs = Math.max(this.gap.toMs, old.timestampMs);
|
|
42
|
+
}
|
|
43
|
+
this.compact();
|
|
44
|
+
}
|
|
45
|
+
/** Removes and returns the pending gap (if any) and up to `maxItems` entries, oldest first. */
|
|
46
|
+
take(maxItems) {
|
|
47
|
+
const gap = this.gap;
|
|
48
|
+
this.gap = null;
|
|
49
|
+
const entries = this.entries.slice(this.head, this.head + maxItems);
|
|
50
|
+
this.head += entries.length;
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
this.bytes -= size(e);
|
|
53
|
+
}
|
|
54
|
+
this.compact();
|
|
55
|
+
return { gap, entries };
|
|
56
|
+
}
|
|
57
|
+
/** Resolves when data arrives, on `wake()`, or after `timeoutMs`. */
|
|
58
|
+
wait(timeoutMs) {
|
|
59
|
+
if (!this.empty) {
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
}
|
|
62
|
+
return new Promise(resolve => {
|
|
63
|
+
const done = () => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
this.waiters = this.waiters.filter(w => w !== done);
|
|
66
|
+
resolve();
|
|
67
|
+
};
|
|
68
|
+
const timer = setTimeout(done, timeoutMs);
|
|
69
|
+
timer.unref();
|
|
70
|
+
this.waiters.push(done);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Wakes a pending `wait`, e.g. to react to a new collector address or shutdown. */
|
|
74
|
+
wake() {
|
|
75
|
+
const waiters = this.waiters;
|
|
76
|
+
this.waiters = [];
|
|
77
|
+
for (const w of waiters) {
|
|
78
|
+
w();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
get length() {
|
|
82
|
+
return this.entries.length - this.head;
|
|
83
|
+
}
|
|
84
|
+
get empty() {
|
|
85
|
+
return this.length === 0 && this.gap === null;
|
|
86
|
+
}
|
|
87
|
+
compact() {
|
|
88
|
+
if (this.head > 1024 && this.head * 2 > this.entries.length) {
|
|
89
|
+
this.entries = this.entries.slice(this.head);
|
|
90
|
+
this.head = 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
exports.LogBuffer = LogBuffer;
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LogMeta, LogShipper } from './shipper';
|
|
2
|
+
/**
|
|
3
|
+
* Wraps an object with level methods (`debug`, `info`, `warn`, `error`, … — e.g.
|
|
4
|
+
* ioBroker's `this.log`). The result has the same interface and forwards every call
|
|
5
|
+
* unchanged; each call is also shipped. Everything else on the object passes through.
|
|
6
|
+
*
|
|
7
|
+
* The library reports its own connection problems to the unwrapped object.
|
|
8
|
+
*/
|
|
9
|
+
export declare function wrapLogger<T extends object>(target: T, shipper: LogShipper, meta?: LogMeta): T;
|
|
10
|
+
/**
|
|
11
|
+
* Hooks into `console.debug/info/log/warn/error`: output stays as it is, each call is
|
|
12
|
+
* also shipped. Arguments are formatted like Node does (`util.format`), so an Error
|
|
13
|
+
* argument keeps its stack trace. Returns a function that restores the original console.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hookConsole(shipper: LogShipper, meta?: LogMeta): () => void;
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.wrapLogger = wrapLogger;
|
|
4
|
+
exports.hookConsole = hookConsole;
|
|
5
|
+
const node_util_1 = require("node:util");
|
|
6
|
+
const LOGGER_METHODS = ['silly', 'trace', 'debug', 'info', 'log', 'warn', 'error', 'fatal'];
|
|
7
|
+
/**
|
|
8
|
+
* Wraps an object with level methods (`debug`, `info`, `warn`, `error`, … — e.g.
|
|
9
|
+
* ioBroker's `this.log`). The result has the same interface and forwards every call
|
|
10
|
+
* unchanged; each call is also shipped. Everything else on the object passes through.
|
|
11
|
+
*
|
|
12
|
+
* The library reports its own connection problems to the unwrapped object.
|
|
13
|
+
*/
|
|
14
|
+
function wrapLogger(target, shipper, meta = {}) {
|
|
15
|
+
const t = target;
|
|
16
|
+
if (typeof t.info === 'function' && typeof t.warn === 'function') {
|
|
17
|
+
shipper.setInternalLog({
|
|
18
|
+
info: m => t.info.call(target, m),
|
|
19
|
+
warn: m => t.warn.call(target, m),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return new Proxy(target, {
|
|
23
|
+
get(obj, prop, receiver) {
|
|
24
|
+
const value = Reflect.get(obj, prop, receiver);
|
|
25
|
+
if (typeof value !== 'function' || !LOGGER_METHODS.includes(prop)) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
return (...args) => {
|
|
29
|
+
const result = value.apply(obj, args);
|
|
30
|
+
shipper.log(prop, (0, node_util_1.format)(...args), meta);
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const CONSOLE_METHODS = ['debug', 'info', 'log', 'warn', 'error'];
|
|
37
|
+
/**
|
|
38
|
+
* Hooks into `console.debug/info/log/warn/error`: output stays as it is, each call is
|
|
39
|
+
* also shipped. Arguments are formatted like Node does (`util.format`), so an Error
|
|
40
|
+
* argument keeps its stack trace. Returns a function that restores the original console.
|
|
41
|
+
*/
|
|
42
|
+
function hookConsole(shipper, meta = {}) {
|
|
43
|
+
const originals = CONSOLE_METHODS.map(m => [m, console[m]]);
|
|
44
|
+
// The library's own messages go to the original console, not back through the hook.
|
|
45
|
+
const original = Object.fromEntries(originals);
|
|
46
|
+
shipper.setInternalLog({
|
|
47
|
+
info: m => original.info.call(console, m),
|
|
48
|
+
warn: m => original.warn.call(console, m),
|
|
49
|
+
});
|
|
50
|
+
for (const [method, fn] of originals) {
|
|
51
|
+
console[method] = (...args) => {
|
|
52
|
+
fn.apply(console, args);
|
|
53
|
+
shipper.log(method, (0, node_util_1.format)(...args), meta);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return () => {
|
|
57
|
+
for (const [method, fn] of originals) {
|
|
58
|
+
console[method] = fn;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ships the logs of a Hannah component to the Hannah log collector.
|
|
3
|
+
*
|
|
4
|
+
* const shipper = new LogShipper({ component: 'msteams', version }); // as early as possible
|
|
5
|
+
* hookConsole(shipper); // or wrapLogger / shipper.hook
|
|
6
|
+
* ...
|
|
7
|
+
* shipper.connect({ hannahAddress: 'localhost:50051' }); // once the config is loaded
|
|
8
|
+
*/
|
|
9
|
+
export { LogShipper, Category, DEFAULT_MAX_BUFFER_BYTES, toLogLevel } from './shipper';
|
|
10
|
+
export type { Level, LogMeta, InternalLog, ShipperOptions, ConnectOptions } from './shipper';
|
|
11
|
+
export { wrapLogger, hookConsole } from './hooks';
|
|
12
|
+
export { SecretFilter, MASK } from './secrets';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MASK = exports.SecretFilter = exports.hookConsole = exports.wrapLogger = exports.toLogLevel = exports.DEFAULT_MAX_BUFFER_BYTES = exports.Category = exports.LogShipper = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Ships the logs of a Hannah component to the Hannah log collector.
|
|
6
|
+
*
|
|
7
|
+
* const shipper = new LogShipper({ component: 'msteams', version }); // as early as possible
|
|
8
|
+
* hookConsole(shipper); // or wrapLogger / shipper.hook
|
|
9
|
+
* ...
|
|
10
|
+
* shipper.connect({ hannahAddress: 'localhost:50051' }); // once the config is loaded
|
|
11
|
+
*/
|
|
12
|
+
var shipper_1 = require("./shipper");
|
|
13
|
+
Object.defineProperty(exports, "LogShipper", { enumerable: true, get: function () { return shipper_1.LogShipper; } });
|
|
14
|
+
Object.defineProperty(exports, "Category", { enumerable: true, get: function () { return shipper_1.Category; } });
|
|
15
|
+
Object.defineProperty(exports, "DEFAULT_MAX_BUFFER_BYTES", { enumerable: true, get: function () { return shipper_1.DEFAULT_MAX_BUFFER_BYTES; } });
|
|
16
|
+
Object.defineProperty(exports, "toLogLevel", { enumerable: true, get: function () { return shipper_1.toLogLevel; } });
|
|
17
|
+
var hooks_1 = require("./hooks");
|
|
18
|
+
Object.defineProperty(exports, "wrapLogger", { enumerable: true, get: function () { return hooks_1.wrapLogger; } });
|
|
19
|
+
Object.defineProperty(exports, "hookConsole", { enumerable: true, get: function () { return hooks_1.hookConsole; } });
|
|
20
|
+
var secrets_1 = require("./secrets");
|
|
21
|
+
Object.defineProperty(exports, "SecretFilter", { enumerable: true, get: function () { return secrets_1.SecretFilter; } });
|
|
22
|
+
Object.defineProperty(exports, "MASK", { enumerable: true, get: function () { return secrets_1.MASK; } });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Masks secrets in a log message before it leaves the process. */
|
|
2
|
+
export declare const MASK = "***";
|
|
3
|
+
/**
|
|
4
|
+
* Masks well-known secret shapes plus literal values the component knows are secret
|
|
5
|
+
* (its own tokens and passwords — the most reliable way to catch them).
|
|
6
|
+
*/
|
|
7
|
+
export declare class SecretFilter {
|
|
8
|
+
private literals;
|
|
9
|
+
private readonly patterns;
|
|
10
|
+
/** @throws SyntaxError for an invalid entry in `patterns` */
|
|
11
|
+
constructor(literals?: Iterable<string>, patterns?: Iterable<string>);
|
|
12
|
+
addLiteral(secret: string): void;
|
|
13
|
+
apply(message: string): string;
|
|
14
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Masks secrets in a log message before it leaves the process. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.SecretFilter = exports.MASK = void 0;
|
|
5
|
+
exports.MASK = '***';
|
|
6
|
+
// Key names whose value is treated as secret. The key has to *end* in one of them,
|
|
7
|
+
// so "bot_token=…" is masked but "tokens=512" or "token_count=3" is not.
|
|
8
|
+
const SECRET_KEYS = 'password|passwd|pwd|passphrase|secret|client[_-]?secret|token|access[_-]?token|refresh[_-]?token' +
|
|
9
|
+
'|api[_-]?key|apikey|access[_-]?key|private[_-]?key|psk|auth|authorization|credentials?';
|
|
10
|
+
function quotedMask(value) {
|
|
11
|
+
const q = value[0];
|
|
12
|
+
if (value.length >= 2 && (q === '"' || q === "'") && value[value.length - 1] === q) {
|
|
13
|
+
return q + exports.MASK + q;
|
|
14
|
+
}
|
|
15
|
+
return exports.MASK;
|
|
16
|
+
}
|
|
17
|
+
const BUILTIN_PATTERNS = [
|
|
18
|
+
// PEM blocks (private keys).
|
|
19
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, exports.MASK],
|
|
20
|
+
// "Authorization: Bearer abc", "Bearer abc"
|
|
21
|
+
[/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+/gi, `$1 ${exports.MASK}`],
|
|
22
|
+
// key=value / key: value / "key": "value"
|
|
23
|
+
[
|
|
24
|
+
new RegExp(`(["']?[\\w.-]*(?:${SECRET_KEYS})["']?)(\\s*[:=]\\s*)("[^"]*"|'[^']*'|[^\\s,;&)}\\]]+)`, 'gi'),
|
|
25
|
+
(_m, key, sep, value) => key + sep + quotedMask(value),
|
|
26
|
+
],
|
|
27
|
+
// Credentials in URLs: scheme://user:password@host
|
|
28
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+):[^/\s@]+@/gi, `$1:${exports.MASK}@`],
|
|
29
|
+
// JWTs
|
|
30
|
+
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, exports.MASK],
|
|
31
|
+
// Telegram bot tokens
|
|
32
|
+
[/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/g, exports.MASK],
|
|
33
|
+
// Well-known token prefixes (GitLab, GitHub, npm, PyPI, Slack, OpenAI-style)
|
|
34
|
+
[/\b(?:glpat|gldt|glrt|ghp|gho|ghu|ghs|ghr|github_pat|npm|pypi|xox[abpr]|sk)[-_][A-Za-z0-9_-]{16,}/g, exports.MASK],
|
|
35
|
+
];
|
|
36
|
+
/**
|
|
37
|
+
* Masks well-known secret shapes plus literal values the component knows are secret
|
|
38
|
+
* (its own tokens and passwords — the most reliable way to catch them).
|
|
39
|
+
*/
|
|
40
|
+
class SecretFilter {
|
|
41
|
+
literals = [];
|
|
42
|
+
patterns;
|
|
43
|
+
/** @throws SyntaxError for an invalid entry in `patterns` */
|
|
44
|
+
constructor(literals = [], patterns = []) {
|
|
45
|
+
this.patterns = [...BUILTIN_PATTERNS, ...[...patterns].map((p) => [new RegExp(p, 'g'), exports.MASK])];
|
|
46
|
+
for (const l of literals) {
|
|
47
|
+
this.addLiteral(l);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
addLiteral(secret) {
|
|
51
|
+
// A 1–3 character "secret" would mangle ordinary text.
|
|
52
|
+
if (!secret || secret.length < 4 || this.literals.includes(secret)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.literals.push(secret);
|
|
56
|
+
// Longest first, so a secret that contains another one is masked as a whole.
|
|
57
|
+
this.literals.sort((a, b) => b.length - a.length);
|
|
58
|
+
}
|
|
59
|
+
apply(message) {
|
|
60
|
+
for (const literal of this.literals) {
|
|
61
|
+
message = message.split(literal).join(exports.MASK);
|
|
62
|
+
}
|
|
63
|
+
for (const [re, replacement] of this.patterns) {
|
|
64
|
+
message =
|
|
65
|
+
typeof replacement === 'string' ? message.replace(re, replacement) : message.replace(re, replacement);
|
|
66
|
+
}
|
|
67
|
+
return message;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.SecretFilter = SecretFilter;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { logging } from '@m1kad0/hannah-proto';
|
|
2
|
+
export declare const DEFAULT_MAX_BUFFER_BYTES: number;
|
|
3
|
+
export declare const Category: {
|
|
4
|
+
readonly GENERAL: logging.LogCategory.LOG_CATEGORY_GENERAL;
|
|
5
|
+
/** speech transcripts / user utterances */
|
|
6
|
+
readonly TRANSCRIPT: logging.LogCategory.LOG_CATEGORY_TRANSCRIPT;
|
|
7
|
+
/** room/device names, presence and similar */
|
|
8
|
+
readonly METADATA: logging.LogCategory.LOG_CATEGORY_METADATA;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Level names as used by common loggers (ioBroker, console, pino, winston), or a
|
|
12
|
+
* LogLevel value. Unknown names count as info.
|
|
13
|
+
*/
|
|
14
|
+
export type Level = 'silly' | 'trace' | 'debug' | 'info' | 'log' | 'warn' | 'warning' | 'error' | 'fatal' | 'critical' | logging.LogLevel;
|
|
15
|
+
export interface LogMeta {
|
|
16
|
+
/** e.g. the module or subsystem; used for `loggerCategories` */
|
|
17
|
+
logger?: string;
|
|
18
|
+
category?: logging.LogCategory;
|
|
19
|
+
/** Unix ms; defaults to now */
|
|
20
|
+
timestamp?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Where the library reports its own connection problems. Never shipped. */
|
|
23
|
+
export interface InternalLog {
|
|
24
|
+
info(message: string): void;
|
|
25
|
+
warn(message: string): void;
|
|
26
|
+
}
|
|
27
|
+
export interface ShipperOptions {
|
|
28
|
+
/** e.g. "iobroker", "msteams" — the name shown in the export */
|
|
29
|
+
component: string;
|
|
30
|
+
/** component version, shown in the export manifest */
|
|
31
|
+
version?: string;
|
|
32
|
+
/** distinguishes several instances; defaults to the hostname */
|
|
33
|
+
instance?: string;
|
|
34
|
+
/** literal values masked wherever they appear (the component's own tokens and passwords) */
|
|
35
|
+
secrets?: string[];
|
|
36
|
+
/** extra regexes to mask, on top of the built-in ones */
|
|
37
|
+
secretPatterns?: string[];
|
|
38
|
+
/** logger name prefix -> category, e.g. { stt: Category.TRANSCRIPT } also covers "stt.whisper" */
|
|
39
|
+
loggerCategories?: Record<string, logging.LogCategory>;
|
|
40
|
+
maxBufferBytes?: number;
|
|
41
|
+
/** defaults to console */
|
|
42
|
+
internalLog?: InternalLog;
|
|
43
|
+
}
|
|
44
|
+
export interface ConnectOptions {
|
|
45
|
+
/** host:port of Hannah Core — enables discovery of the collector */
|
|
46
|
+
hannahAddress?: string;
|
|
47
|
+
/** host:port of the collector — fallback while none is announced */
|
|
48
|
+
collectorAddress?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function toLogLevel(level: Level): logging.LogLevel;
|
|
51
|
+
export declare function formatAddress(host: string, port: number): string;
|
|
52
|
+
/**
|
|
53
|
+
* Buffers log lines from the moment it is created and ships them to the log collector
|
|
54
|
+
* once one is known. Feed it through `hook` (any logger), `wrapLogger` or `hookConsole`.
|
|
55
|
+
*/
|
|
56
|
+
export declare class LogShipper {
|
|
57
|
+
/** The hook: call it for every log line, e.g. from a pino hook or a winston transport. */
|
|
58
|
+
readonly hook: (level: Level, message: string, meta?: LogMeta) => void;
|
|
59
|
+
private readonly buffer;
|
|
60
|
+
private readonly secrets;
|
|
61
|
+
private readonly categories;
|
|
62
|
+
private readonly hello;
|
|
63
|
+
private internalLog;
|
|
64
|
+
private reportingInternally;
|
|
65
|
+
private connected;
|
|
66
|
+
private stopping;
|
|
67
|
+
private staticAddress;
|
|
68
|
+
private discovered;
|
|
69
|
+
private shipperDone;
|
|
70
|
+
private discoveryCall;
|
|
71
|
+
private sleepers;
|
|
72
|
+
constructor(opts: ShipperOptions);
|
|
73
|
+
/** Buffers one log line. Never throws, never blocks. */
|
|
74
|
+
log(level: Level, message: string, meta?: LogMeta): void;
|
|
75
|
+
/** Masks this literal value in everything logged from now on. */
|
|
76
|
+
addSecret(secret: string): void;
|
|
77
|
+
/** Where the library reports its own connection problems (default: console). */
|
|
78
|
+
setInternalLog(log: InternalLog): void;
|
|
79
|
+
/** Starts shipping. Only the first call has an effect. */
|
|
80
|
+
connect(opts: ConnectOptions): void;
|
|
81
|
+
/** Stops discovery and gives the shipper up to `timeoutMs` to send what is still buffered. */
|
|
82
|
+
close(timeoutMs?: number): Promise<void>;
|
|
83
|
+
private categoryFor;
|
|
84
|
+
private report;
|
|
85
|
+
private target;
|
|
86
|
+
/**
|
|
87
|
+
* Resolves after `ms`, or earlier on close or when the collector address changes.
|
|
88
|
+
* Returns false once closing.
|
|
89
|
+
*/
|
|
90
|
+
private sleep;
|
|
91
|
+
private wakeSleepers;
|
|
92
|
+
private runDiscovery;
|
|
93
|
+
private subscribeOnce;
|
|
94
|
+
private setDiscovered;
|
|
95
|
+
private runShipper;
|
|
96
|
+
private shipOnce;
|
|
97
|
+
}
|
package/dist/shipper.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.LogShipper = exports.Category = exports.DEFAULT_MAX_BUFFER_BYTES = void 0;
|
|
37
|
+
exports.toLogLevel = toLogLevel;
|
|
38
|
+
exports.formatAddress = formatAddress;
|
|
39
|
+
const node_events_1 = require("node:events");
|
|
40
|
+
const node_os_1 = require("node:os");
|
|
41
|
+
const grpc = __importStar(require("@grpc/grpc-js"));
|
|
42
|
+
const hannah_proto_1 = require("@m1kad0/hannah-proto");
|
|
43
|
+
const buffer_1 = require("./buffer");
|
|
44
|
+
const secrets_1 = require("./secrets");
|
|
45
|
+
exports.DEFAULT_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
46
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
47
|
+
const CONNECT_TIMEOUT_MS = 5_000;
|
|
48
|
+
const BATCH_SIZE = 200;
|
|
49
|
+
exports.Category = {
|
|
50
|
+
GENERAL: hannah_proto_1.logging.LogCategory.LOG_CATEGORY_GENERAL,
|
|
51
|
+
/** speech transcripts / user utterances */
|
|
52
|
+
TRANSCRIPT: hannah_proto_1.logging.LogCategory.LOG_CATEGORY_TRANSCRIPT,
|
|
53
|
+
/** room/device names, presence and similar */
|
|
54
|
+
METADATA: hannah_proto_1.logging.LogCategory.LOG_CATEGORY_METADATA,
|
|
55
|
+
};
|
|
56
|
+
const LEVELS = {
|
|
57
|
+
silly: hannah_proto_1.logging.LogLevel.LOG_LEVEL_DEBUG,
|
|
58
|
+
trace: hannah_proto_1.logging.LogLevel.LOG_LEVEL_DEBUG,
|
|
59
|
+
debug: hannah_proto_1.logging.LogLevel.LOG_LEVEL_DEBUG,
|
|
60
|
+
info: hannah_proto_1.logging.LogLevel.LOG_LEVEL_INFO,
|
|
61
|
+
log: hannah_proto_1.logging.LogLevel.LOG_LEVEL_INFO,
|
|
62
|
+
warn: hannah_proto_1.logging.LogLevel.LOG_LEVEL_WARNING,
|
|
63
|
+
warning: hannah_proto_1.logging.LogLevel.LOG_LEVEL_WARNING,
|
|
64
|
+
error: hannah_proto_1.logging.LogLevel.LOG_LEVEL_ERROR,
|
|
65
|
+
fatal: hannah_proto_1.logging.LogLevel.LOG_LEVEL_CRITICAL,
|
|
66
|
+
critical: hannah_proto_1.logging.LogLevel.LOG_LEVEL_CRITICAL,
|
|
67
|
+
};
|
|
68
|
+
function toLogLevel(level) {
|
|
69
|
+
return typeof level === 'number' ? level : (LEVELS[level] ?? hannah_proto_1.logging.LogLevel.LOG_LEVEL_INFO);
|
|
70
|
+
}
|
|
71
|
+
const protocolVersionInterceptor = (options, nextCall) => new grpc.InterceptingCall(nextCall(options), {
|
|
72
|
+
start(metadata, listener, next) {
|
|
73
|
+
metadata.set('x-proto-version', String(hannah_proto_1.PROTO_VERSION));
|
|
74
|
+
next(metadata, listener);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const CLIENT_OPTIONS = {
|
|
78
|
+
interceptors: [protocolVersionInterceptor, hannah_proto_1.compat_interceptor.compatVersionInterceptor],
|
|
79
|
+
};
|
|
80
|
+
function formatAddress(host, port) {
|
|
81
|
+
if (host.includes(':') && !host.startsWith('[')) {
|
|
82
|
+
host = `[${host}]`; // IPv6
|
|
83
|
+
}
|
|
84
|
+
return `${host}:${port}`;
|
|
85
|
+
}
|
|
86
|
+
function errorText(err) {
|
|
87
|
+
return err instanceof Error ? err.message : String(err);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Buffers log lines from the moment it is created and ships them to the log collector
|
|
91
|
+
* once one is known. Feed it through `hook` (any logger), `wrapLogger` or `hookConsole`.
|
|
92
|
+
*/
|
|
93
|
+
class LogShipper {
|
|
94
|
+
/** The hook: call it for every log line, e.g. from a pino hook or a winston transport. */
|
|
95
|
+
hook;
|
|
96
|
+
buffer;
|
|
97
|
+
secrets;
|
|
98
|
+
categories;
|
|
99
|
+
hello;
|
|
100
|
+
internalLog;
|
|
101
|
+
reportingInternally = false;
|
|
102
|
+
connected = false;
|
|
103
|
+
stopping = false;
|
|
104
|
+
staticAddress = '';
|
|
105
|
+
discovered = '';
|
|
106
|
+
shipperDone = Promise.resolve();
|
|
107
|
+
discoveryCall = null;
|
|
108
|
+
sleepers = [];
|
|
109
|
+
constructor(opts) {
|
|
110
|
+
if (!opts.component) {
|
|
111
|
+
throw new Error('hannah-logging: component is required');
|
|
112
|
+
}
|
|
113
|
+
this.buffer = new buffer_1.LogBuffer(opts.maxBufferBytes ?? exports.DEFAULT_MAX_BUFFER_BYTES);
|
|
114
|
+
this.secrets = new secrets_1.SecretFilter(opts.secrets, opts.secretPatterns);
|
|
115
|
+
this.categories = Object.entries(opts.loggerCategories ?? {}).sort((a, b) => b[0].length - a[0].length);
|
|
116
|
+
this.hello = { component: opts.component, instance: opts.instance || (0, node_os_1.hostname)(), version: opts.version ?? '' };
|
|
117
|
+
this.internalLog = opts.internalLog ?? console;
|
|
118
|
+
this.hook = (level, message, meta) => this.log(level, message, meta);
|
|
119
|
+
}
|
|
120
|
+
/** Buffers one log line. Never throws, never blocks. */
|
|
121
|
+
log(level, message, meta = {}) {
|
|
122
|
+
if (this.reportingInternally) {
|
|
123
|
+
return; // the library's own message, e.g. through a hooked console
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const logger = meta.logger ?? '';
|
|
127
|
+
this.buffer.put({
|
|
128
|
+
timestampMs: meta.timestamp ?? Date.now(),
|
|
129
|
+
level: toLogLevel(level),
|
|
130
|
+
logger,
|
|
131
|
+
message: this.secrets.apply(String(message)),
|
|
132
|
+
category: meta.category || this.categoryFor(logger),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// never let logging break the component
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Masks this literal value in everything logged from now on. */
|
|
140
|
+
addSecret(secret) {
|
|
141
|
+
this.secrets.addLiteral(secret);
|
|
142
|
+
}
|
|
143
|
+
/** Where the library reports its own connection problems (default: console). */
|
|
144
|
+
setInternalLog(log) {
|
|
145
|
+
this.internalLog = log;
|
|
146
|
+
}
|
|
147
|
+
/** Starts shipping. Only the first call has an effect. */
|
|
148
|
+
connect(opts) {
|
|
149
|
+
if (this.connected || this.stopping) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.connected = true;
|
|
153
|
+
this.staticAddress = opts.collectorAddress ?? '';
|
|
154
|
+
this.shipperDone = this.runShipper();
|
|
155
|
+
if (opts.hannahAddress) {
|
|
156
|
+
void this.runDiscovery(opts.hannahAddress);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Stops discovery and gives the shipper up to `timeoutMs` to send what is still buffered. */
|
|
160
|
+
async close(timeoutMs = 2000) {
|
|
161
|
+
if (this.stopping) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.stopping = true;
|
|
165
|
+
this.discoveryCall?.cancel();
|
|
166
|
+
this.wakeSleepers();
|
|
167
|
+
this.buffer.wake();
|
|
168
|
+
let timer;
|
|
169
|
+
await Promise.race([this.shipperDone, new Promise(r => (timer = setTimeout(r, timeoutMs)))]);
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
172
|
+
categoryFor(logger) {
|
|
173
|
+
for (const [prefix, category] of this.categories) {
|
|
174
|
+
if (logger === prefix || logger.startsWith(`${prefix}.`)) {
|
|
175
|
+
return category;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return exports.Category.GENERAL;
|
|
179
|
+
}
|
|
180
|
+
report(level, message) {
|
|
181
|
+
this.reportingInternally = true;
|
|
182
|
+
try {
|
|
183
|
+
this.internalLog[level](`[hannah-logging] ${message}`);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// ignore
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
this.reportingInternally = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
target() {
|
|
193
|
+
return this.discovered || this.staticAddress;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Resolves after `ms`, or earlier on close or when the collector address changes.
|
|
197
|
+
* Returns false once closing.
|
|
198
|
+
*/
|
|
199
|
+
sleep(ms) {
|
|
200
|
+
if (this.stopping) {
|
|
201
|
+
return Promise.resolve(false);
|
|
202
|
+
}
|
|
203
|
+
return new Promise(resolve => {
|
|
204
|
+
const done = () => {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
this.sleepers = this.sleepers.filter(w => w !== done);
|
|
207
|
+
resolve(!this.stopping);
|
|
208
|
+
};
|
|
209
|
+
const timer = setTimeout(done, ms);
|
|
210
|
+
timer.unref();
|
|
211
|
+
this.sleepers.push(done);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
wakeSleepers() {
|
|
215
|
+
for (const w of this.sleepers.slice()) {
|
|
216
|
+
w();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// ------------------------------------------------------------------
|
|
220
|
+
// Discovery: follows Hannah Core's infrastructure broadcast. If Core becomes
|
|
221
|
+
// unreachable, the last known collector is kept — the collector doesn't depend on
|
|
222
|
+
// Core, so logs keep flowing. The snapshot after a reconnect replaces the known state.
|
|
223
|
+
// ------------------------------------------------------------------
|
|
224
|
+
async runDiscovery(address) {
|
|
225
|
+
let backoff = 1000;
|
|
226
|
+
let failing = false;
|
|
227
|
+
const collectors = new Map(); // instance -> address
|
|
228
|
+
while (!this.stopping) {
|
|
229
|
+
const err = await this.subscribeOnce(address, collectors, () => {
|
|
230
|
+
if (failing) {
|
|
231
|
+
this.report('info', `log collector discovery: connected to Hannah at ${address}`);
|
|
232
|
+
failing = false;
|
|
233
|
+
}
|
|
234
|
+
backoff = 1000;
|
|
235
|
+
});
|
|
236
|
+
if (this.stopping) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!failing) {
|
|
240
|
+
this.report('warn', `log collector discovery: Hannah at ${address} unreachable (${err}), retrying`);
|
|
241
|
+
failing = true;
|
|
242
|
+
}
|
|
243
|
+
if (!(await this.sleep(backoff))) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
subscribeOnce(address, collectors, onMessage) {
|
|
250
|
+
return new Promise(resolve => {
|
|
251
|
+
const client = new hannah_proto_1.hannah.HannahServiceClient(address, grpc.credentials.createInsecure(), CLIENT_OPTIONS);
|
|
252
|
+
const call = client.subscribeInfrastructure({
|
|
253
|
+
kinds: [hannah_proto_1.infrastructure.ServiceKind.SERVICE_KIND_LOG_COLLECTOR],
|
|
254
|
+
});
|
|
255
|
+
this.discoveryCall = call;
|
|
256
|
+
let settled = false;
|
|
257
|
+
const finish = (reason) => {
|
|
258
|
+
if (settled) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
settled = true;
|
|
262
|
+
this.discoveryCall = null;
|
|
263
|
+
client.close();
|
|
264
|
+
resolve(reason);
|
|
265
|
+
};
|
|
266
|
+
call.on('data', (msg) => {
|
|
267
|
+
onMessage();
|
|
268
|
+
if (applyInfrastructure(msg, collectors)) {
|
|
269
|
+
this.setDiscovered(pickCollector(collectors));
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
call.on('error', (err) => finish(errorText(err)));
|
|
273
|
+
call.on('end', () => finish('stream ended'));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
setDiscovered(address) {
|
|
277
|
+
if (address === this.discovered) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.discovered = address;
|
|
281
|
+
if (address) {
|
|
282
|
+
this.report('info', `log collector discovered at ${address}`);
|
|
283
|
+
}
|
|
284
|
+
this.wakeSleepers();
|
|
285
|
+
this.buffer.wake();
|
|
286
|
+
}
|
|
287
|
+
// ------------------------------------------------------------------
|
|
288
|
+
// Shipper: streams the buffer to the collector over LogService.Ship. Only opens the
|
|
289
|
+
// stream once the channel is ready, so nothing leaves the buffer while the collector
|
|
290
|
+
// is unreachable. Ends the stream when the collector address changes.
|
|
291
|
+
// ------------------------------------------------------------------
|
|
292
|
+
async runShipper() {
|
|
293
|
+
let backoff = 1000;
|
|
294
|
+
let failing = false;
|
|
295
|
+
while (!this.stopping) {
|
|
296
|
+
const target = this.target();
|
|
297
|
+
if (!target) {
|
|
298
|
+
// No collector known: the buffer keeps running as a ring. Wait for an
|
|
299
|
+
// address (setDiscovered wakes the sleep) — not for data, which may already
|
|
300
|
+
// be buffered and would turn this into a busy loop.
|
|
301
|
+
await this.sleep(1000);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await this.shipOnce(target, () => {
|
|
306
|
+
if (failing) {
|
|
307
|
+
this.report('info', `log collector at ${target} reachable again`);
|
|
308
|
+
failing = false;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
backoff = 1000;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
if (this.stopping) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (!failing) {
|
|
319
|
+
this.report('warn', `log collector at ${target}: ${errorText(err)} — buffering, retrying`);
|
|
320
|
+
failing = true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!(await this.sleep(backoff))) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async shipOnce(target, onConnected) {
|
|
330
|
+
const client = new hannah_proto_1.logging.LogServiceClient(target, grpc.credentials.createInsecure(), CLIENT_OPTIONS);
|
|
331
|
+
try {
|
|
332
|
+
await new Promise((resolve, reject) => client.waitForReady(Date.now() + CONNECT_TIMEOUT_MS, err => (err ? reject(new Error('not reachable')) : resolve())));
|
|
333
|
+
onConnected();
|
|
334
|
+
// Settles when the collector answers (ShipAck) or the call fails.
|
|
335
|
+
let call;
|
|
336
|
+
const callDone = new Promise((resolve, reject) => {
|
|
337
|
+
call = client.ship(err => (err ? reject(err) : resolve()));
|
|
338
|
+
});
|
|
339
|
+
let settled = false;
|
|
340
|
+
callDone.then(() => (settled = true), () => (settled = true));
|
|
341
|
+
const write = async (msg) => {
|
|
342
|
+
if (!call.write(msg)) {
|
|
343
|
+
await Promise.race([(0, node_events_1.once)(call, 'drain'), callDone]);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
await write({ hello: this.hello });
|
|
347
|
+
for (;;) {
|
|
348
|
+
const { gap, entries } = this.buffer.take(BATCH_SIZE);
|
|
349
|
+
if (gap) {
|
|
350
|
+
await write({
|
|
351
|
+
gap: { dropped: BigInt(gap.dropped), fromMs: BigInt(gap.fromMs), toMs: BigInt(gap.toMs) },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
for (const e of entries) {
|
|
355
|
+
await write({
|
|
356
|
+
entry: {
|
|
357
|
+
timestampMs: BigInt(e.timestampMs),
|
|
358
|
+
level: e.level,
|
|
359
|
+
logger: e.logger,
|
|
360
|
+
message: e.message,
|
|
361
|
+
category: e.category,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const drained = !gap && entries.length === 0;
|
|
366
|
+
// Collector moved: what's left in the buffer goes to the new one.
|
|
367
|
+
// Shutdown: close the stream only once the buffer is drained.
|
|
368
|
+
if (this.target() !== target || (this.stopping && drained)) {
|
|
369
|
+
call.end();
|
|
370
|
+
await callDone;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (drained) {
|
|
374
|
+
await Promise.race([this.buffer.wait(1000), callDone]);
|
|
375
|
+
}
|
|
376
|
+
if (settled) {
|
|
377
|
+
// The call ended without us closing it: rethrow its error, if any.
|
|
378
|
+
await callDone;
|
|
379
|
+
throw new Error('collector closed the stream');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
client.close();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
exports.LogShipper = LogShipper;
|
|
389
|
+
/** Applies `msg` to `collectors` and reports whether it was relevant. */
|
|
390
|
+
function applyInfrastructure(msg, collectors) {
|
|
391
|
+
const LOG_COLLECTOR = hannah_proto_1.infrastructure.ServiceKind.SERVICE_KIND_LOG_COLLECTOR;
|
|
392
|
+
if (msg.snapshot) {
|
|
393
|
+
collectors.clear();
|
|
394
|
+
for (const s of msg.snapshot.services) {
|
|
395
|
+
if (s.kind === LOG_COLLECTOR) {
|
|
396
|
+
collectors.set(s.instance, formatAddress(s.host, s.port));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
if (msg.available?.service) {
|
|
402
|
+
const s = msg.available.service;
|
|
403
|
+
if (s.kind !== LOG_COLLECTOR) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
collectors.set(s.instance, formatAddress(s.host, s.port));
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
if (msg.unavailable) {
|
|
410
|
+
if (msg.unavailable.kind !== LOG_COLLECTOR) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
collectors.delete(msg.unavailable.instance);
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
/** Chooses one collector deterministically; several are unusual. */
|
|
419
|
+
function pickCollector(collectors) {
|
|
420
|
+
const instances = [...collectors.keys()].sort();
|
|
421
|
+
return instances.length ? collectors.get(instances[0]) : '';
|
|
422
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@m1kad0/hannah-logging",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Ships the logs of a Hannah component to the Hannah log collector",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://gitlab.com/gessinger/hannah-logging-libs.git",
|
|
9
|
+
"directory": "ts"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">= 22"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"test": "tsc -p tsconfig.test.json && node --test --test-timeout=30000 \"build-test/**/*.test.js\""
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@grpc/grpc-js": "^1.14.4",
|
|
28
|
+
"@m1kad0/hannah-proto": "^4.5.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@grpc/grpc-js": "1.14.4",
|
|
32
|
+
"@m1kad0/hannah-proto": "4.5.0",
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "~6.0.3"
|
|
35
|
+
}
|
|
36
|
+
}
|