@chronicle.app/logging 0.3.0 → 0.5.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 +53 -10
- package/dist/Logger.d.ts +24 -10
- package/dist/Logger.js +64 -92
- package/dist/aggregate.d.ts +42 -0
- package/dist/aggregate.js +149 -0
- package/dist/console.d.ts +8 -0
- package/dist/console.js +27 -0
- package/dist/defaultSink.d.ts +11 -0
- package/dist/defaultSink.js +14 -0
- package/dist/errors.d.ts +78 -0
- package/dist/errors.js +108 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/levels.d.ts +10 -0
- package/dist/levels.js +11 -0
- package/dist/sinks.d.ts +66 -0
- package/dist/sinks.js +202 -0
- package/dist/types.d.ts +97 -7
- package/package.json +2 -5
- package/dist/DefaultTheme.d.ts +0 -3
- package/dist/DefaultTheme.js +0 -8
package/README.md
CHANGED
|
@@ -1,22 +1,65 @@
|
|
|
1
1
|
# @chronicle.app/logging
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Chronicle's output events and the sinks that render them. Node.js 22.13+; ESM
|
|
4
|
+
with TypeScript declarations.
|
|
4
5
|
|
|
5
6
|
```js
|
|
6
|
-
import { createLogger } from '@chronicle.app/logging';
|
|
7
|
+
import { createLogger, JsonSink } from '@chronicle.app/logging';
|
|
7
8
|
|
|
8
|
-
const logger = createLogger({
|
|
9
|
+
const logger = createLogger({ scope: 'example', verbose: true });
|
|
9
10
|
logger.info('Extracted records', { count: 3 });
|
|
10
11
|
logger.debug('Source cursor', { position: 3 });
|
|
12
|
+
|
|
13
|
+
// A host hands its own sink over; events then render its way.
|
|
14
|
+
logger.use(new JsonSink(), { run: { id: 'run-1', source: 'example', strategy: 'file' } });
|
|
15
|
+
logger.warn('Line skipped', { line: 42 });
|
|
11
16
|
```
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
+
Each logger call becomes an `OutputEvent`: a `kind`, a `level`, the logger's
|
|
19
|
+
`scope` and `run`, a plain `message`, and the second argument as `fields`.
|
|
20
|
+
`debug` and `verboseInfo` are debug-level diagnostics, `info` and `warn` are
|
|
21
|
+
notices, and `error` is an error. `emit` sends any event, including the host's
|
|
22
|
+
`progress`, `hint`, and `summary`.
|
|
23
|
+
|
|
24
|
+
A sink decides what to show and how. `TextSink` writes lines to stderr; by
|
|
25
|
+
default each is `HH:MM:SS level scope: message key=value`, and a host passes
|
|
26
|
+
its own `format`. `JsonSink` writes one JSON object per line, turns progress
|
|
27
|
+
into a heartbeat, and redacts the fields an event marks `sensitive` unless
|
|
28
|
+
`personal` is set. Both filter by `level` (`thresholdFor({ quiet, verbose })`
|
|
29
|
+
maps the common flags), and both keep repeated events from flooding.
|
|
30
|
+
|
|
31
|
+
Events group by their `key`, or without one by scope and message with the
|
|
32
|
+
numbers taken out (`Generated 1 actions` and `Generated 3 actions` are one
|
|
33
|
+
group). Each group shows its first three events, then counts the rest and
|
|
34
|
+
reports them as a roll-up (`…412 more like this in 5s`) at most every five
|
|
35
|
+
seconds, or every second for warnings and errors. A roll-up's fields hold
|
|
36
|
+
the count and how often each field value came up; personal values go under
|
|
37
|
+
`personal`, which JSON redacts. `flush()` reports what's left as `…and N more
|
|
38
|
+
like this`, and a `summary` event flushes first. Pass `aggregate: { examples,
|
|
39
|
+
windowMs, urgentWindowMs }` to a sink to change the numbers.
|
|
40
|
+
|
|
41
|
+
A logger without a sink of its own uses the process-wide one a host sets
|
|
42
|
+
with `setDefaultSink`, so a host can redirect every logger, including
|
|
43
|
+
module-level ones in shared packages. Without either, it writes plain lines
|
|
44
|
+
to stderr at the level its `quiet`, `verbose`, and `level` options ask for;
|
|
45
|
+
errors always print. With `sensitive: true` every field it logs is marked
|
|
46
|
+
personal.
|
|
47
|
+
|
|
48
|
+
`captureConsole(logger)` routes `console.*` through a logger as diagnostics
|
|
49
|
+
until the function it returns restores the console, for code the host doesn't
|
|
50
|
+
control. Chronicle's own code never calls `console`; lint enforces it.
|
|
51
|
+
|
|
52
|
+
## Errors
|
|
18
53
|
|
|
19
|
-
`
|
|
20
|
-
|
|
54
|
+
`ExtractorError` and its subclasses name a failure so hosts can act on it
|
|
55
|
+
without reading the message: `AuthRequired` (exit code 3), `PermissionDenied`
|
|
56
|
+
and `InputNotFound` (4), `RateLimited` with `retryAfter` (5), and
|
|
57
|
+
`ExtractorError` itself with any `code` (1 unless given). Each carries a
|
|
58
|
+
`hint`, the next step for a person, and `fields`, which are personal.
|
|
59
|
+
`EXIT_CODES` lists the codes: 1 internal, 2 usage, 3 auth, 4 input, 5
|
|
60
|
+
transient. `describeError` reads the code, exit code, and hint of any thrown
|
|
61
|
+
value, duck-typed so an error from another copy of this package still reads
|
|
62
|
+
as what it is. `markReported` and `isReported` let a host that already
|
|
63
|
+
reported an error as an event avoid printing it twice.
|
|
21
64
|
|
|
22
65
|
MIT. See [LICENSE](LICENSE).
|
package/dist/Logger.d.ts
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
|
-
import { LoggerOptions,
|
|
1
|
+
import type { LoggerContext, LoggerOptions, OutputEvent, RunContext, Sink } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Emits events to a sink. Each call becomes an {@link OutputEvent} with this
|
|
4
|
+
* logger's scope and run; the second argument becomes the event's `fields`.
|
|
5
|
+
* Without a sink of its own, a logger follows the process-wide default
|
|
6
|
+
* ({@link setDefaultSink}), and without that prints plain lines on stderr.
|
|
7
|
+
*/
|
|
2
8
|
export declare class Logger {
|
|
3
|
-
private
|
|
4
|
-
private
|
|
5
|
-
private
|
|
6
|
-
private
|
|
7
|
-
private
|
|
9
|
+
private scope;
|
|
10
|
+
private sink?;
|
|
11
|
+
private fallback?;
|
|
12
|
+
private run?;
|
|
13
|
+
private readonly sensitive;
|
|
14
|
+
private readonly options;
|
|
8
15
|
constructor(options?: LoggerOptions);
|
|
9
|
-
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
/** This logger's sink, else the process default, else plain stderr at its options' level. */
|
|
17
|
+
private target;
|
|
18
|
+
/** Send events to the host's sink from now on, for its run, under `scope` if given. */
|
|
19
|
+
use(sink: Sink, { run, scope }?: {
|
|
20
|
+
run?: RunContext;
|
|
21
|
+
scope?: string;
|
|
22
|
+
}): void;
|
|
23
|
+
/** Emit an event of any kind; `time`, `scope`, and `run` default to this logger's. */
|
|
24
|
+
emit(event: Omit<OutputEvent, 'time' | 'scope'> & Partial<OutputEvent>): void;
|
|
25
|
+
flush(): void;
|
|
26
|
+
private log;
|
|
13
27
|
debug(message: string, context?: LoggerContext): void;
|
|
14
28
|
debugMultiline(message: string, context?: LoggerContext): void;
|
|
15
29
|
info(message: string, context?: LoggerContext): void;
|
package/dist/Logger.js
CHANGED
|
@@ -1,112 +1,84 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { thresholdFor } from './levels.js';
|
|
2
|
+
import { defaultSink } from './defaultSink.js';
|
|
3
|
+
import { TextSink } from './sinks.js';
|
|
4
|
+
/** `[ServiceName]` → `ServiceName`. */
|
|
5
|
+
const scopeOf = (prefix) => prefix.match(/\[([^\]]+)\]/)?.[1] ?? prefix.trim();
|
|
6
|
+
/**
|
|
7
|
+
* Emits events to a sink. Each call becomes an {@link OutputEvent} with this
|
|
8
|
+
* logger's scope and run; the second argument becomes the event's `fields`.
|
|
9
|
+
* Without a sink of its own, a logger follows the process-wide default
|
|
10
|
+
* ({@link setDefaultSink}), and without that prints plain lines on stderr.
|
|
11
|
+
*/
|
|
9
12
|
export class Logger {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
scope;
|
|
14
|
+
sink;
|
|
15
|
+
fallback;
|
|
16
|
+
run;
|
|
17
|
+
sensitive;
|
|
18
|
+
options;
|
|
15
19
|
constructor(options = {}) {
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
18
|
-
this.
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
21
|
-
// Only enable colors when writing to a real terminal (not when PM2 captures stderr)
|
|
22
|
-
if (process.stderr.isTTY) {
|
|
23
|
-
chalk.level = 3; // Force full color support for terminals
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
chalk.level = 0; // Disable colors for file/pipe output
|
|
27
|
-
}
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.scope = options.scope ?? (options.prefix ? scopeOf(options.prefix) : '');
|
|
22
|
+
this.sink = options.sink;
|
|
23
|
+
this.run = options.run;
|
|
24
|
+
this.sensitive = options.sensitive ?? false;
|
|
28
25
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
36
|
-
|
|
26
|
+
/** This logger's sink, else the process default, else plain stderr at its options' level. */
|
|
27
|
+
target() {
|
|
28
|
+
if (this.sink)
|
|
29
|
+
return this.sink;
|
|
30
|
+
const host = defaultSink();
|
|
31
|
+
if (host)
|
|
32
|
+
return host;
|
|
33
|
+
this.fallback ??= new TextSink({ level: thresholdFor(this.options) });
|
|
34
|
+
return this.fallback;
|
|
37
35
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
/** Send events to the host's sink from now on, for its run, under `scope` if given. */
|
|
37
|
+
use(sink, { run, scope } = {}) {
|
|
38
|
+
this.sink = sink;
|
|
39
|
+
if (run)
|
|
40
|
+
this.run = run;
|
|
41
|
+
if (scope)
|
|
42
|
+
this.scope = scope;
|
|
44
43
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
formatted += ` ${JSON.stringify(context)}`;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return formatted;
|
|
44
|
+
/** Emit an event of any kind; `time`, `scope`, and `run` default to this logger's. */
|
|
45
|
+
emit(event) {
|
|
46
|
+
this.target().emit({
|
|
47
|
+
time: new Date(),
|
|
48
|
+
scope: this.scope,
|
|
49
|
+
...(this.run && { run: this.run }),
|
|
50
|
+
...event,
|
|
51
|
+
});
|
|
59
52
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.map(([key, value]) => `${nextIndent}"${key}": ${this.formatCompactJson(value, depth + 1)}`)
|
|
73
|
-
.join(',\n');
|
|
74
|
-
return `{\n${formatted}\n${indent}}`;
|
|
75
|
-
}
|
|
76
|
-
return JSON.stringify(obj);
|
|
53
|
+
flush() {
|
|
54
|
+
this.target().flush?.();
|
|
55
|
+
}
|
|
56
|
+
log(level, kind, message, context) {
|
|
57
|
+
const fields = context && Object.keys(context).length > 0 ? context : undefined;
|
|
58
|
+
this.emit({
|
|
59
|
+
level,
|
|
60
|
+
kind,
|
|
61
|
+
message,
|
|
62
|
+
...(fields && { fields }),
|
|
63
|
+
...(fields && this.sensitive && { sensitive: Object.keys(fields) }),
|
|
64
|
+
});
|
|
77
65
|
}
|
|
78
66
|
debug(message, context) {
|
|
79
|
-
|
|
80
|
-
const formatted = this.formatMessage(message, context);
|
|
81
|
-
process.stderr.write(`${this.theme.debug(formatted)}\n`);
|
|
82
|
-
}
|
|
67
|
+
this.log('debug', 'diagnostic', message, context);
|
|
83
68
|
}
|
|
84
69
|
debugMultiline(message, context) {
|
|
85
|
-
|
|
86
|
-
const formatted = this.formatMessage(message, context, true);
|
|
87
|
-
process.stderr.write(`${this.theme.debug(formatted)}\n`);
|
|
88
|
-
}
|
|
70
|
+
this.log('debug', 'diagnostic', message, context);
|
|
89
71
|
}
|
|
90
72
|
info(message, context) {
|
|
91
|
-
|
|
92
|
-
const formatted = this.formatMessage(message, context);
|
|
93
|
-
process.stderr.write(`${this.theme.info(formatted)}\n`);
|
|
94
|
-
}
|
|
73
|
+
this.log('info', 'notice', message, context);
|
|
95
74
|
}
|
|
96
75
|
verboseInfo(message, context) {
|
|
97
|
-
|
|
98
|
-
const formatted = this.formatMessage(message, context);
|
|
99
|
-
process.stderr.write(`${this.theme.info(formatted)}\n`);
|
|
100
|
-
}
|
|
76
|
+
this.log('debug', 'diagnostic', message, context);
|
|
101
77
|
}
|
|
102
78
|
warn(message, context) {
|
|
103
|
-
|
|
104
|
-
const formatted = this.formatMessage(message, context);
|
|
105
|
-
process.stderr.write(`${this.theme.warn(formatted)}\n`);
|
|
106
|
-
}
|
|
79
|
+
this.log('warn', 'notice', message, context);
|
|
107
80
|
}
|
|
108
81
|
error(message, context) {
|
|
109
|
-
|
|
110
|
-
process.stderr.write(`${this.theme.error(formatted)}\n`);
|
|
82
|
+
this.log('error', 'error', message, context);
|
|
111
83
|
}
|
|
112
84
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OutputEvent } from './types.js';
|
|
2
|
+
/** Counts of each value a field took, and how many values went untracked. */
|
|
3
|
+
export type ValueCounts = Record<string, number>;
|
|
4
|
+
export interface AggregatorOptions {
|
|
5
|
+
/** Events shown per group before the rest are counted. Default 3. */
|
|
6
|
+
examples?: number;
|
|
7
|
+
/** Most often a group's roll-up prints, in milliseconds. Default 5s. */
|
|
8
|
+
windowMs?: number;
|
|
9
|
+
/** The same for warnings and errors, which shouldn't wait as long. Default 1s. */
|
|
10
|
+
urgentWindowMs?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The group an event belongs to: its `key`, else its scope and message with
|
|
14
|
+
* the numbers taken out. Personal values belong in fields, so a message is
|
|
15
|
+
* already close to a template: `Generated 1 actions` and `Generated 3
|
|
16
|
+
* actions` group together.
|
|
17
|
+
*/
|
|
18
|
+
export declare function groupOf(event: OutputEvent): string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Keeps repeated events from flooding: each group shows its first few
|
|
21
|
+
* events, then counts the rest and reports them as a roll-up at most once a
|
|
22
|
+
* window, with how often each field value came up. 10,000 failed records
|
|
23
|
+
* become three examples and a line every second or so, not 10,000 lines.
|
|
24
|
+
*/
|
|
25
|
+
export declare class Aggregator {
|
|
26
|
+
private groups;
|
|
27
|
+
private readonly examples;
|
|
28
|
+
private readonly windowMs;
|
|
29
|
+
private readonly urgentWindowMs;
|
|
30
|
+
constructor(options?: AggregatorOptions | number);
|
|
31
|
+
/** What to print now for this event: roll-ups that came due, then the event if it shows. */
|
|
32
|
+
admit(event: OutputEvent): OutputEvent[];
|
|
33
|
+
/** Every pending roll-up, now: for a summary, a flush, or exit. */
|
|
34
|
+
drain(): OutputEvent[];
|
|
35
|
+
/** Whether any group is holding events back. */
|
|
36
|
+
get pending(): boolean;
|
|
37
|
+
/** Roll-ups whose window has passed by `now`. */
|
|
38
|
+
due(now: number): OutputEvent[];
|
|
39
|
+
private count;
|
|
40
|
+
private rollup;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=aggregate.d.ts.map
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Distinct values tracked per field in a group, so memory stays bounded. */
|
|
2
|
+
const MAX_VALUES = 50;
|
|
3
|
+
const MAX_VALUE_LENGTH = 60;
|
|
4
|
+
/** Kinds that repeat per record, and so group without being asked. */
|
|
5
|
+
const GROUPABLE = new Set(['notice', 'diagnostic', 'error']);
|
|
6
|
+
/**
|
|
7
|
+
* The group an event belongs to: its `key`, else its scope and message with
|
|
8
|
+
* the numbers taken out. Personal values belong in fields, so a message is
|
|
9
|
+
* already close to a template: `Generated 1 actions` and `Generated 3
|
|
10
|
+
* actions` group together.
|
|
11
|
+
*/
|
|
12
|
+
export function groupOf(event) {
|
|
13
|
+
if (event.key)
|
|
14
|
+
return event.key;
|
|
15
|
+
if (!GROUPABLE.has(event.kind))
|
|
16
|
+
return undefined;
|
|
17
|
+
return `${event.scope}\u0000${event.message.replaceAll(/\d+/g, '#')}`;
|
|
18
|
+
}
|
|
19
|
+
const text = (value) => {
|
|
20
|
+
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
21
|
+
return s.length > MAX_VALUE_LENGTH ? `${s.slice(0, MAX_VALUE_LENGTH - 1)}…` : s;
|
|
22
|
+
};
|
|
23
|
+
const seconds = (ms) => `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
24
|
+
/**
|
|
25
|
+
* Keeps repeated events from flooding: each group shows its first few
|
|
26
|
+
* events, then counts the rest and reports them as a roll-up at most once a
|
|
27
|
+
* window, with how often each field value came up. 10,000 failed records
|
|
28
|
+
* become three examples and a line every second or so, not 10,000 lines.
|
|
29
|
+
*/
|
|
30
|
+
export class Aggregator {
|
|
31
|
+
groups = new Map();
|
|
32
|
+
examples;
|
|
33
|
+
windowMs;
|
|
34
|
+
urgentWindowMs;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
const o = typeof options === 'number' ? { examples: options } : options;
|
|
37
|
+
this.examples = o.examples ?? 3;
|
|
38
|
+
this.windowMs = o.windowMs ?? 5000;
|
|
39
|
+
this.urgentWindowMs = o.urgentWindowMs ?? 1000;
|
|
40
|
+
}
|
|
41
|
+
/** What to print now for this event: roll-ups that came due, then the event if it shows. */
|
|
42
|
+
admit(event) {
|
|
43
|
+
const now = event.time.getTime();
|
|
44
|
+
const out = this.due(now);
|
|
45
|
+
const key = groupOf(event);
|
|
46
|
+
if (!key)
|
|
47
|
+
return [...out, event];
|
|
48
|
+
let group = this.groups.get(key);
|
|
49
|
+
if (!group) {
|
|
50
|
+
group = {
|
|
51
|
+
key,
|
|
52
|
+
shown: 0,
|
|
53
|
+
pending: 0,
|
|
54
|
+
last: event,
|
|
55
|
+
since: now,
|
|
56
|
+
values: new Map(),
|
|
57
|
+
personal: new Set(),
|
|
58
|
+
};
|
|
59
|
+
this.groups.set(key, group);
|
|
60
|
+
}
|
|
61
|
+
group.last = event;
|
|
62
|
+
if (group.shown < this.examples) {
|
|
63
|
+
group.shown++;
|
|
64
|
+
group.since = now;
|
|
65
|
+
out.push(event);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
group.pending++;
|
|
69
|
+
this.count(group, event);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** Every pending roll-up, now: for a summary, a flush, or exit. */
|
|
73
|
+
drain() {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const group of this.groups.values()) {
|
|
76
|
+
if (group.pending > 0)
|
|
77
|
+
out.push(this.rollup(group, group.last.time.getTime(), true));
|
|
78
|
+
}
|
|
79
|
+
this.groups.clear();
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/** Whether any group is holding events back. */
|
|
83
|
+
get pending() {
|
|
84
|
+
for (const group of this.groups.values())
|
|
85
|
+
if (group.pending > 0)
|
|
86
|
+
return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/** Roll-ups whose window has passed by `now`. */
|
|
90
|
+
due(now) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const group of this.groups.values()) {
|
|
93
|
+
const urgent = group.last.level === 'warn' || group.last.level === 'error';
|
|
94
|
+
const window = urgent ? this.urgentWindowMs : this.windowMs;
|
|
95
|
+
if (group.pending > 0 && now - group.since >= window)
|
|
96
|
+
out.push(this.rollup(group, now));
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
count(group, event) {
|
|
101
|
+
for (const [field, value] of Object.entries(event.fields ?? {})) {
|
|
102
|
+
if (value === undefined || value === null || typeof value === 'object')
|
|
103
|
+
continue;
|
|
104
|
+
if (event.sensitive?.includes(field))
|
|
105
|
+
group.personal.add(field);
|
|
106
|
+
const counts = group.values.get(field) ?? new Map();
|
|
107
|
+
const label = text(value);
|
|
108
|
+
if (counts.has(label) || counts.size < MAX_VALUES) {
|
|
109
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
counts.set('…', (counts.get('…') ?? 0) + 1);
|
|
113
|
+
}
|
|
114
|
+
group.values.set(field, counts);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
rollup(group, now, final = false) {
|
|
118
|
+
const { last, pending } = group;
|
|
119
|
+
const values = {};
|
|
120
|
+
const personal = {};
|
|
121
|
+
for (const [field, counts] of group.values) {
|
|
122
|
+
(group.personal.has(field) ? personal : values)[field] = Object.fromEntries(counts);
|
|
123
|
+
}
|
|
124
|
+
const count = pending.toLocaleString('en-US');
|
|
125
|
+
const event = {
|
|
126
|
+
time: new Date(now),
|
|
127
|
+
level: last.level,
|
|
128
|
+
kind: last.kind,
|
|
129
|
+
scope: last.scope,
|
|
130
|
+
...(last.run && { run: last.run }),
|
|
131
|
+
message: final
|
|
132
|
+
? `…and ${count} more like this`
|
|
133
|
+
: `…${count} more like this in ${seconds(now - group.since)}`,
|
|
134
|
+
fields: {
|
|
135
|
+
suppressed: pending,
|
|
136
|
+
windowMs: now - group.since,
|
|
137
|
+
...(Object.keys(values).length > 0 && { values }),
|
|
138
|
+
...(Object.keys(personal).length > 0 && { personal }),
|
|
139
|
+
},
|
|
140
|
+
...(Object.keys(personal).length > 0 && { sensitive: ['personal'] }),
|
|
141
|
+
key: group.key,
|
|
142
|
+
};
|
|
143
|
+
group.pending = 0;
|
|
144
|
+
group.since = now;
|
|
145
|
+
group.values.clear();
|
|
146
|
+
group.personal.clear();
|
|
147
|
+
return event;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Logger } from './Logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Route `console.*` through `logger` as diagnostics until the returned
|
|
4
|
+
* function restores it: code that writes to the console (a third-party
|
|
5
|
+
* plugin, a dependency) still reaches the host's sink, never stdout.
|
|
6
|
+
*/
|
|
7
|
+
export declare function captureConsole(logger: Logger): () => void;
|
|
8
|
+
//# sourceMappingURL=console.d.ts.map
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { format } from 'node:util';
|
|
2
|
+
const METHODS = {
|
|
3
|
+
debug: 'debug',
|
|
4
|
+
trace: 'debug',
|
|
5
|
+
log: 'info',
|
|
6
|
+
info: 'info',
|
|
7
|
+
warn: 'warn',
|
|
8
|
+
error: 'error',
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Route `console.*` through `logger` as diagnostics until the returned
|
|
12
|
+
* function restores it: code that writes to the console (a third-party
|
|
13
|
+
* plugin, a dependency) still reaches the host's sink, never stdout.
|
|
14
|
+
*/
|
|
15
|
+
export function captureConsole(logger) {
|
|
16
|
+
const original = new Map();
|
|
17
|
+
for (const [method, level] of Object.entries(METHODS)) {
|
|
18
|
+
original.set(method, console[method]);
|
|
19
|
+
console[method] = (...args) => {
|
|
20
|
+
logger.emit({ level, kind: 'diagnostic', message: format(...args) });
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return () => {
|
|
24
|
+
for (const [method, fn] of original)
|
|
25
|
+
console[method] = fn;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Sink } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where loggers without a sink of their own send events, process-wide: a host
|
|
4
|
+
* sets this once so module-level loggers (in shared packages, or created
|
|
5
|
+
* before a run hands its sink over) follow its output instead of stderr.
|
|
6
|
+
* Returns the sink it replaced, so a caller can restore it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setDefaultSink(sink: Sink | undefined): Sink | undefined;
|
|
9
|
+
/** The process-wide sink, if a host set one. */
|
|
10
|
+
export declare const defaultSink: () => Sink | undefined;
|
|
11
|
+
//# sourceMappingURL=defaultSink.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
let current;
|
|
2
|
+
/**
|
|
3
|
+
* Where loggers without a sink of their own send events, process-wide: a host
|
|
4
|
+
* sets this once so module-level loggers (in shared packages, or created
|
|
5
|
+
* before a run hands its sink over) follow its output instead of stderr.
|
|
6
|
+
* Returns the sink it replaced, so a caller can restore it.
|
|
7
|
+
*/
|
|
8
|
+
export function setDefaultSink(sink) {
|
|
9
|
+
const previous = current;
|
|
10
|
+
current = sink;
|
|
11
|
+
return previous;
|
|
12
|
+
}
|
|
13
|
+
/** The process-wide sink, if a host set one. */
|
|
14
|
+
export const defaultSink = () => current;
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes a supervisor can act on without reading messages: whether to
|
|
3
|
+
* report a bug, fix the invocation, get a person to re-authorize or fix
|
|
4
|
+
* access, or retry later.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EXIT_CODES: {
|
|
7
|
+
readonly internal: 1;
|
|
8
|
+
readonly usage: 2;
|
|
9
|
+
readonly auth: 3;
|
|
10
|
+
readonly input: 4;
|
|
11
|
+
readonly transient: 5;
|
|
12
|
+
};
|
|
13
|
+
export interface ExtractorErrorOptions {
|
|
14
|
+
/** A stable name for what went wrong: `auth-required`, `input-not-found`. */
|
|
15
|
+
code?: string;
|
|
16
|
+
exitCode?: number;
|
|
17
|
+
/** The next move for a person: `run chronicle auth login arena`. */
|
|
18
|
+
hint?: string;
|
|
19
|
+
/** Facts about the failure. Personal unless listed in `safe`. */
|
|
20
|
+
fields?: Record<string, unknown>;
|
|
21
|
+
cause?: unknown;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A failure a plugin can name. The runner reports it as an `error` event with
|
|
25
|
+
* its code, exit code, and hint, and the CLI exits with its code, so a person
|
|
26
|
+
* gets a next step and a supervisor can decide what to do without matching
|
|
27
|
+
* text. Put personal values (paths, names) in `fields`, not the message.
|
|
28
|
+
*/
|
|
29
|
+
export declare class ExtractorError extends Error {
|
|
30
|
+
readonly code: string;
|
|
31
|
+
readonly exitCode: number;
|
|
32
|
+
readonly hint?: string;
|
|
33
|
+
readonly fields?: Record<string, unknown>;
|
|
34
|
+
constructor(message: string, options?: ExtractorErrorOptions);
|
|
35
|
+
}
|
|
36
|
+
/** No credentials, or they expired or were revoked: a person has to sign in. */
|
|
37
|
+
export declare class AuthRequired extends ExtractorError {
|
|
38
|
+
constructor(message: string, options?: Omit<ExtractorErrorOptions, 'code' | 'exitCode'> & {
|
|
39
|
+
source?: string;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** The operating system refused access: permissions, or macOS privacy controls. */
|
|
43
|
+
export declare class PermissionDenied extends ExtractorError {
|
|
44
|
+
constructor(message: string, options?: Omit<ExtractorErrorOptions, 'code' | 'exitCode'> & {
|
|
45
|
+
path?: string;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** The input the source reads from isn't there, or isn't what it expects. */
|
|
49
|
+
export declare class InputNotFound extends ExtractorError {
|
|
50
|
+
constructor(message: string, options?: Omit<ExtractorErrorOptions, 'code' | 'exitCode'> & {
|
|
51
|
+
path?: string;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** The source throttled us; `retryAfter` says how long to wait, when it said. */
|
|
55
|
+
export declare class RateLimited extends ExtractorError {
|
|
56
|
+
readonly retryAfter?: number;
|
|
57
|
+
constructor(message: string, options?: Omit<ExtractorErrorOptions, 'code' | 'exitCode'> & {
|
|
58
|
+
retryAfter?: number;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The code, exit code, and hint of any thrown value, typed or not. Duck-typed,
|
|
63
|
+
* so an error from a plugin built against another copy of this package still
|
|
64
|
+
* reads as what it is.
|
|
65
|
+
*/
|
|
66
|
+
export declare function describeError(error: unknown): {
|
|
67
|
+
message: string;
|
|
68
|
+
code: string;
|
|
69
|
+
exitCode: number;
|
|
70
|
+
hint?: string;
|
|
71
|
+
fields?: Record<string, unknown>;
|
|
72
|
+
stack?: string;
|
|
73
|
+
};
|
|
74
|
+
/** Note that an error was already reported as an event, so a host doesn't print it twice. */
|
|
75
|
+
export declare function markReported(error: unknown): void;
|
|
76
|
+
/** Whether an error was already reported as an event. */
|
|
77
|
+
export declare const isReported: (error: unknown) => boolean;
|
|
78
|
+
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes a supervisor can act on without reading messages: whether to
|
|
3
|
+
* report a bug, fix the invocation, get a person to re-authorize or fix
|
|
4
|
+
* access, or retry later.
|
|
5
|
+
*/
|
|
6
|
+
export const EXIT_CODES = {
|
|
7
|
+
internal: 1,
|
|
8
|
+
usage: 2,
|
|
9
|
+
auth: 3,
|
|
10
|
+
input: 4,
|
|
11
|
+
transient: 5,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A failure a plugin can name. The runner reports it as an `error` event with
|
|
15
|
+
* its code, exit code, and hint, and the CLI exits with its code, so a person
|
|
16
|
+
* gets a next step and a supervisor can decide what to do without matching
|
|
17
|
+
* text. Put personal values (paths, names) in `fields`, not the message.
|
|
18
|
+
*/
|
|
19
|
+
export class ExtractorError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
exitCode;
|
|
22
|
+
hint;
|
|
23
|
+
fields;
|
|
24
|
+
constructor(message, options = {}) {
|
|
25
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
26
|
+
this.name = new.target.name;
|
|
27
|
+
this.code = options.code ?? 'extractor-error';
|
|
28
|
+
this.exitCode = options.exitCode ?? EXIT_CODES.internal;
|
|
29
|
+
this.hint = options.hint;
|
|
30
|
+
this.fields = options.fields;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** No credentials, or they expired or were revoked: a person has to sign in. */
|
|
34
|
+
export class AuthRequired extends ExtractorError {
|
|
35
|
+
constructor(message, options = {}) {
|
|
36
|
+
const { source, ...rest } = options;
|
|
37
|
+
super(message, {
|
|
38
|
+
code: 'auth-required',
|
|
39
|
+
exitCode: EXIT_CODES.auth,
|
|
40
|
+
hint: source ? `Run \`chronicle auth login ${source}\` to sign in.` : undefined,
|
|
41
|
+
...rest,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The operating system refused access: permissions, or macOS privacy controls. */
|
|
46
|
+
export class PermissionDenied extends ExtractorError {
|
|
47
|
+
constructor(message, options = {}) {
|
|
48
|
+
const { path, ...rest } = options;
|
|
49
|
+
super(message, {
|
|
50
|
+
code: 'permission-denied',
|
|
51
|
+
exitCode: EXIT_CODES.input,
|
|
52
|
+
...rest,
|
|
53
|
+
fields: { ...(path && { path }), ...rest.fields },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The input the source reads from isn't there, or isn't what it expects. */
|
|
58
|
+
export class InputNotFound extends ExtractorError {
|
|
59
|
+
constructor(message, options = {}) {
|
|
60
|
+
const { path, ...rest } = options;
|
|
61
|
+
super(message, {
|
|
62
|
+
code: 'input-not-found',
|
|
63
|
+
exitCode: EXIT_CODES.input,
|
|
64
|
+
...rest,
|
|
65
|
+
fields: { ...(path && { path }), ...rest.fields },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The source throttled us; `retryAfter` says how long to wait, when it said. */
|
|
70
|
+
export class RateLimited extends ExtractorError {
|
|
71
|
+
retryAfter;
|
|
72
|
+
constructor(message, options = {}) {
|
|
73
|
+
const { retryAfter, ...rest } = options;
|
|
74
|
+
super(message, {
|
|
75
|
+
code: 'rate-limited',
|
|
76
|
+
exitCode: EXIT_CODES.transient,
|
|
77
|
+
hint: retryAfter === undefined ? 'Try again later.' : `Try again in ${retryAfter}s.`,
|
|
78
|
+
...rest,
|
|
79
|
+
fields: { ...(retryAfter !== undefined && { retryAfter }), ...rest.fields },
|
|
80
|
+
});
|
|
81
|
+
this.retryAfter = retryAfter;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The code, exit code, and hint of any thrown value, typed or not. Duck-typed,
|
|
86
|
+
* so an error from a plugin built against another copy of this package still
|
|
87
|
+
* reads as what it is.
|
|
88
|
+
*/
|
|
89
|
+
export function describeError(error) {
|
|
90
|
+
const e = error;
|
|
91
|
+
const typed = typeof e?.code === 'string' && typeof e?.exitCode === 'number';
|
|
92
|
+
return {
|
|
93
|
+
message: error instanceof Error ? error.message : String(error),
|
|
94
|
+
code: typed ? e.code : 'internal',
|
|
95
|
+
exitCode: typed ? e.exitCode : EXIT_CODES.internal,
|
|
96
|
+
...(typed && e.hint && { hint: e.hint }),
|
|
97
|
+
...(typed && e.fields && { fields: e.fields }),
|
|
98
|
+
...(e?.stack && { stack: e.stack }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const REPORTED = Symbol.for('chronicle.error.reported');
|
|
102
|
+
/** Note that an error was already reported as an event, so a host doesn't print it twice. */
|
|
103
|
+
export function markReported(error) {
|
|
104
|
+
if (typeof error === 'object' && error !== null)
|
|
105
|
+
error[REPORTED] = true;
|
|
106
|
+
}
|
|
107
|
+
/** Whether an error was already reported as an event. */
|
|
108
|
+
export const isReported = (error) => typeof error === 'object' && error !== null && error[REPORTED] === true;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export { Logger } from './Logger.js';
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
2
|
+
export { AuthRequired, EXIT_CODES, ExtractorError, InputNotFound, PermissionDenied, RateLimited, describeError, isReported, markReported, type ExtractorErrorOptions, } from './errors.js';
|
|
3
|
+
export { Aggregator, groupOf, type AggregatorOptions, type ValueCounts } from './aggregate.js';
|
|
4
|
+
export { captureConsole } from './console.js';
|
|
5
|
+
export { defaultSink, setDefaultSink } from './defaultSink.js';
|
|
6
|
+
export { allows, thresholdFor } from './levels.js';
|
|
7
|
+
export { JsonSink, REDACTED, TextSink, formatFields, formatPlain, timeOfDay, toJson, type JsonSinkOptions, type TextSinkOptions, } from './sinks.js';
|
|
8
|
+
export type { EventKind, LoggerContext, LoggerOptions, LogLevel, OutputEvent, ProgressFields, RunContext, SummaryFields, Sink, } from './types.js';
|
|
4
9
|
import { Logger } from './Logger.js';
|
|
5
10
|
import { LoggerOptions } from './types.js';
|
|
6
11
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { Logger } from './Logger.js';
|
|
2
|
-
export {
|
|
2
|
+
export { AuthRequired, EXIT_CODES, ExtractorError, InputNotFound, PermissionDenied, RateLimited, describeError, isReported, markReported, } from './errors.js';
|
|
3
|
+
export { Aggregator, groupOf } from './aggregate.js';
|
|
4
|
+
export { captureConsole } from './console.js';
|
|
5
|
+
export { defaultSink, setDefaultSink } from './defaultSink.js';
|
|
6
|
+
export { allows, thresholdFor } from './levels.js';
|
|
7
|
+
export { JsonSink, REDACTED, TextSink, formatFields, formatPlain, timeOfDay, toJson, } from './sinks.js';
|
|
3
8
|
import { Logger } from './Logger.js';
|
|
4
9
|
/**
|
|
5
10
|
* Factory function to create a logger instance
|
package/dist/levels.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LogLevel } from './types.js';
|
|
2
|
+
/** Whether a sink set to `threshold` shows an event at `level`. */
|
|
3
|
+
export declare const allows: (threshold: LogLevel, level: LogLevel) => boolean;
|
|
4
|
+
/** The threshold the common flags ask for: `--quiet` shows errors, `--verbose` everything. */
|
|
5
|
+
export declare function thresholdFor({ quiet, verbose, level, }: {
|
|
6
|
+
quiet?: boolean;
|
|
7
|
+
verbose?: boolean;
|
|
8
|
+
level?: LogLevel;
|
|
9
|
+
}): LogLevel;
|
|
10
|
+
//# sourceMappingURL=levels.d.ts.map
|
package/dist/levels.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
2
|
+
/** Whether a sink set to `threshold` shows an event at `level`. */
|
|
3
|
+
export const allows = (threshold, level) => ORDER[level] >= ORDER[threshold];
|
|
4
|
+
/** The threshold the common flags ask for: `--quiet` shows errors, `--verbose` everything. */
|
|
5
|
+
export function thresholdFor({ quiet, verbose, level, }) {
|
|
6
|
+
if (quiet)
|
|
7
|
+
return 'error';
|
|
8
|
+
if (verbose)
|
|
9
|
+
return 'debug';
|
|
10
|
+
return level ?? 'info';
|
|
11
|
+
}
|
package/dist/sinks.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type AggregatorOptions } from './aggregate.js';
|
|
2
|
+
import type { LogLevel, OutputEvent, Sink } from './types.js';
|
|
3
|
+
/** `HH:MM:SS` in local time. */
|
|
4
|
+
export declare const timeOfDay: (date: Date) => string;
|
|
5
|
+
/** `key=value` pairs, values compact: strings bare, the rest as JSON. */
|
|
6
|
+
export declare function formatFields(fields: Record<string, unknown> | undefined): string;
|
|
7
|
+
/** The default line for a host that brings no formatter: time, level, scope, message, fields. */
|
|
8
|
+
export declare function formatPlain(event: OutputEvent): string;
|
|
9
|
+
export interface TextSinkOptions {
|
|
10
|
+
/** The quietest level shown. Default `info`. */
|
|
11
|
+
level?: LogLevel;
|
|
12
|
+
write?: (text: string) => void;
|
|
13
|
+
/** An event as text, or undefined to say nothing. Default {@link formatPlain}. */
|
|
14
|
+
format?: (event: OutputEvent) => string | undefined;
|
|
15
|
+
/** How repeated events group: examples shown, then roll-ups per window. */
|
|
16
|
+
aggregate?: AggregatorOptions;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Events as lines of text for a person. Progress is left to sinks that can
|
|
20
|
+
* draw it; keyed events aggregate, and the counts print before the summary.
|
|
21
|
+
*/
|
|
22
|
+
export declare class TextSink implements Sink {
|
|
23
|
+
readonly level: LogLevel;
|
|
24
|
+
protected readonly write: (text: string) => void;
|
|
25
|
+
private readonly format;
|
|
26
|
+
private readonly aggregator;
|
|
27
|
+
private readonly rollups;
|
|
28
|
+
/** Hints from a run, printed under its summary rather than mid-run. */
|
|
29
|
+
private held;
|
|
30
|
+
private summarized;
|
|
31
|
+
constructor(options?: TextSinkOptions);
|
|
32
|
+
emit(event: OutputEvent): void;
|
|
33
|
+
flush(): void;
|
|
34
|
+
/** Report what the groups held back. */
|
|
35
|
+
protected drain(): void;
|
|
36
|
+
/** Print held hints: a run's, or all of them. */
|
|
37
|
+
private release;
|
|
38
|
+
protected print(event: OutputEvent): void;
|
|
39
|
+
}
|
|
40
|
+
export declare const REDACTED = "[redacted]";
|
|
41
|
+
/** An event as one JSON object, its personal fields redacted. */
|
|
42
|
+
export declare function toJson(event: OutputEvent): Record<string, unknown>;
|
|
43
|
+
export interface JsonSinkOptions {
|
|
44
|
+
level?: LogLevel;
|
|
45
|
+
write?: (text: string) => void;
|
|
46
|
+
/** At most one progress event per run in this many milliseconds. Default 10s. */
|
|
47
|
+
heartbeatMs?: number;
|
|
48
|
+
aggregate?: AggregatorOptions;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Events as JSON lines for a daemon or supervisor. Progress becomes a
|
|
52
|
+
* heartbeat, hints stay data, and personal fields are redacted.
|
|
53
|
+
*/
|
|
54
|
+
export declare class JsonSink implements Sink {
|
|
55
|
+
readonly level: LogLevel;
|
|
56
|
+
private readonly write;
|
|
57
|
+
private readonly heartbeatMs;
|
|
58
|
+
private readonly aggregator;
|
|
59
|
+
private readonly rollups;
|
|
60
|
+
private lastBeat;
|
|
61
|
+
constructor(options?: JsonSinkOptions);
|
|
62
|
+
emit(event: OutputEvent): void;
|
|
63
|
+
flush(): void;
|
|
64
|
+
private print;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=sinks.d.ts.map
|
package/dist/sinks.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Aggregator } from './aggregate.js';
|
|
2
|
+
import { allows } from './levels.js';
|
|
3
|
+
/** Resolved at each write, so a live view that wraps stderr still sees it. */
|
|
4
|
+
const stderr = (text) => {
|
|
5
|
+
process.stderr.write(text);
|
|
6
|
+
};
|
|
7
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
8
|
+
/** `HH:MM:SS` in local time. */
|
|
9
|
+
export const timeOfDay = (date) => `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
|
10
|
+
/** `key=value` pairs, values compact: strings bare, the rest as JSON. */
|
|
11
|
+
export function formatFields(fields) {
|
|
12
|
+
if (!fields)
|
|
13
|
+
return '';
|
|
14
|
+
return Object.entries(fields)
|
|
15
|
+
.filter(([, value]) => value !== undefined)
|
|
16
|
+
.map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`)
|
|
17
|
+
.join(' ');
|
|
18
|
+
}
|
|
19
|
+
/** The default line for a host that brings no formatter: time, level, scope, message, fields. */
|
|
20
|
+
export function formatPlain(event) {
|
|
21
|
+
const level = event.level === 'info' ? '' : `${event.level} `;
|
|
22
|
+
const fields = formatFields(event.fields);
|
|
23
|
+
const hint = event.hint ? ` · ${event.hint.action}` : '';
|
|
24
|
+
const guide = event.guide
|
|
25
|
+
? [
|
|
26
|
+
...(event.guide.text ?? []),
|
|
27
|
+
...(event.guide.steps ?? []).map((step, i) => `${i + 1}. ${step}`),
|
|
28
|
+
].map(part => `\n ${part}`)
|
|
29
|
+
: [];
|
|
30
|
+
return `${timeOfDay(event.time)} ${level}${event.scope}: ${event.message}${hint}${fields ? ` ${fields}` : ''}${guide.join('')}`;
|
|
31
|
+
}
|
|
32
|
+
/** How often a sink checks for roll-ups that came due while nothing else arrived. */
|
|
33
|
+
const TICK_MS = 1000;
|
|
34
|
+
/**
|
|
35
|
+
* Prints roll-ups on time even when a group goes quiet: while events are held
|
|
36
|
+
* back, a timer checks once a second. It never keeps the process alive.
|
|
37
|
+
*/
|
|
38
|
+
class RollupTimer {
|
|
39
|
+
aggregator;
|
|
40
|
+
print;
|
|
41
|
+
timer;
|
|
42
|
+
constructor(aggregator, print) {
|
|
43
|
+
this.aggregator = aggregator;
|
|
44
|
+
this.print = print;
|
|
45
|
+
}
|
|
46
|
+
arm() {
|
|
47
|
+
if (this.timer || !this.aggregator.pending)
|
|
48
|
+
return;
|
|
49
|
+
this.timer = setInterval(() => {
|
|
50
|
+
for (const event of this.aggregator.due(Date.now()))
|
|
51
|
+
this.print(event);
|
|
52
|
+
if (!this.aggregator.pending)
|
|
53
|
+
this.stop();
|
|
54
|
+
}, TICK_MS);
|
|
55
|
+
this.timer.unref();
|
|
56
|
+
}
|
|
57
|
+
stop() {
|
|
58
|
+
clearInterval(this.timer);
|
|
59
|
+
this.timer = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Events as lines of text for a person. Progress is left to sinks that can
|
|
64
|
+
* draw it; keyed events aggregate, and the counts print before the summary.
|
|
65
|
+
*/
|
|
66
|
+
export class TextSink {
|
|
67
|
+
level;
|
|
68
|
+
write;
|
|
69
|
+
format;
|
|
70
|
+
aggregator;
|
|
71
|
+
rollups;
|
|
72
|
+
/** Hints from a run, printed under its summary rather than mid-run. */
|
|
73
|
+
held = [];
|
|
74
|
+
summarized = new Set();
|
|
75
|
+
constructor(options = {}) {
|
|
76
|
+
this.level = options.level ?? 'info';
|
|
77
|
+
this.write = options.write ?? stderr;
|
|
78
|
+
this.format = options.format ?? formatPlain;
|
|
79
|
+
this.aggregator = new Aggregator(options.aggregate);
|
|
80
|
+
this.rollups = new RollupTimer(this.aggregator, event => this.print(event));
|
|
81
|
+
}
|
|
82
|
+
emit(event) {
|
|
83
|
+
if (!allows(this.level, event.level) || event.kind === 'progress')
|
|
84
|
+
return;
|
|
85
|
+
// Hints go under the summary: a run's hints wait for it (or for a flush,
|
|
86
|
+
// if the run fails). Hints outside a run print as they come.
|
|
87
|
+
if (event.kind === 'hint' && event.run && !this.summarized.has(event.run.id)) {
|
|
88
|
+
this.held.push(event);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (event.kind === 'summary')
|
|
92
|
+
this.drain();
|
|
93
|
+
for (const shown of this.aggregator.admit(event))
|
|
94
|
+
this.print(shown);
|
|
95
|
+
if (event.kind === 'summary') {
|
|
96
|
+
if (event.run)
|
|
97
|
+
this.summarized.add(event.run.id);
|
|
98
|
+
this.release(event.run?.id);
|
|
99
|
+
}
|
|
100
|
+
this.rollups.arm();
|
|
101
|
+
}
|
|
102
|
+
flush() {
|
|
103
|
+
this.drain();
|
|
104
|
+
this.release();
|
|
105
|
+
}
|
|
106
|
+
/** Report what the groups held back. */
|
|
107
|
+
drain() {
|
|
108
|
+
this.rollups.stop();
|
|
109
|
+
for (const event of this.aggregator.drain()) {
|
|
110
|
+
if (allows(this.level, event.level))
|
|
111
|
+
this.print(event);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Print held hints: a run's, or all of them. */
|
|
115
|
+
release(run) {
|
|
116
|
+
const now = this.held.filter(event => run === undefined || event.run?.id === run);
|
|
117
|
+
this.held = this.held.filter(event => !now.includes(event));
|
|
118
|
+
for (const event of now)
|
|
119
|
+
this.print(event);
|
|
120
|
+
}
|
|
121
|
+
print(event) {
|
|
122
|
+
const text = this.format(event);
|
|
123
|
+
if (text !== undefined)
|
|
124
|
+
this.write(`${text}\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export const REDACTED = '[redacted]';
|
|
128
|
+
/** An event as one JSON object, its personal fields redacted. */
|
|
129
|
+
export function toJson(event) {
|
|
130
|
+
let { fields } = event;
|
|
131
|
+
if (fields && event.sensitive?.length) {
|
|
132
|
+
fields = Object.fromEntries(Object.entries(fields).map(([key, value]) => [
|
|
133
|
+
key,
|
|
134
|
+
event.sensitive.includes(key) ? REDACTED : value,
|
|
135
|
+
]));
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
time: event.time.toISOString(),
|
|
139
|
+
level: event.level,
|
|
140
|
+
kind: event.kind,
|
|
141
|
+
scope: event.scope,
|
|
142
|
+
...(event.run && { run: event.run }),
|
|
143
|
+
message: event.message,
|
|
144
|
+
...(fields && { fields }),
|
|
145
|
+
...(event.error && { error: event.error }),
|
|
146
|
+
...(event.hint && { hint: event.hint }),
|
|
147
|
+
...(event.guide && { guide: event.guide }),
|
|
148
|
+
...(event.sensitive?.length && { sensitive: event.sensitive }),
|
|
149
|
+
...(event.key && { key: event.key }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Events as JSON lines for a daemon or supervisor. Progress becomes a
|
|
154
|
+
* heartbeat, hints stay data, and personal fields are redacted.
|
|
155
|
+
*/
|
|
156
|
+
export class JsonSink {
|
|
157
|
+
level;
|
|
158
|
+
write;
|
|
159
|
+
heartbeatMs;
|
|
160
|
+
aggregator;
|
|
161
|
+
rollups;
|
|
162
|
+
lastBeat = new Map();
|
|
163
|
+
constructor(options = {}) {
|
|
164
|
+
this.level = options.level ?? 'info';
|
|
165
|
+
this.write = options.write ?? stderr;
|
|
166
|
+
this.heartbeatMs = options.heartbeatMs ?? 10_000;
|
|
167
|
+
this.aggregator = new Aggregator(options.aggregate);
|
|
168
|
+
this.rollups = new RollupTimer(this.aggregator, event => this.print(event));
|
|
169
|
+
}
|
|
170
|
+
emit(event) {
|
|
171
|
+
if (event.kind === 'progress') {
|
|
172
|
+
// Progress is debug-level chatter, but its heartbeat is how a supervisor
|
|
173
|
+
// knows a run is alive: shown at the default threshold, not under --quiet.
|
|
174
|
+
if (!allows(this.level, 'info'))
|
|
175
|
+
return;
|
|
176
|
+
const run = event.run?.id ?? '';
|
|
177
|
+
const last = this.lastBeat.get(run);
|
|
178
|
+
if (last !== undefined && event.time.getTime() - last < this.heartbeatMs)
|
|
179
|
+
return;
|
|
180
|
+
this.lastBeat.set(run, event.time.getTime());
|
|
181
|
+
this.print(event);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!allows(this.level, event.level))
|
|
185
|
+
return;
|
|
186
|
+
if (event.kind === 'summary')
|
|
187
|
+
this.flush();
|
|
188
|
+
for (const shown of this.aggregator.admit(event))
|
|
189
|
+
this.print(shown);
|
|
190
|
+
this.rollups.arm();
|
|
191
|
+
}
|
|
192
|
+
flush() {
|
|
193
|
+
this.rollups.stop();
|
|
194
|
+
for (const event of this.aggregator.drain()) {
|
|
195
|
+
if (allows(this.level, event.level))
|
|
196
|
+
this.print(event);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
print(event) {
|
|
200
|
+
this.write(`${JSON.stringify(toJson(event))}\n`);
|
|
201
|
+
}
|
|
202
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,18 +1,108 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
/** What an event is, apart from how loud it is. */
|
|
3
|
+
export type EventKind = 'progress' | 'notice' | 'hint' | 'summary' | 'error' | 'diagnostic' | 'guide';
|
|
4
|
+
/** The run an event belongs to; a daemon groups events by it, like a trace id. */
|
|
5
|
+
export interface RunContext {
|
|
6
|
+
id: string;
|
|
7
|
+
source: string;
|
|
8
|
+
strategy: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* One thing worth telling a person or a supervisor. Every line on stderr is
|
|
12
|
+
* the rendering of one of these; a sink decides how it looks.
|
|
13
|
+
*/
|
|
14
|
+
export interface OutputEvent {
|
|
15
|
+
time: Date;
|
|
16
|
+
level: LogLevel;
|
|
17
|
+
kind: EventKind;
|
|
18
|
+
/** Who spoke: `runner`, `shell.history`, `cli`. */
|
|
19
|
+
scope: string;
|
|
20
|
+
run?: RunContext;
|
|
21
|
+
/** Plain text, no styling, that reads on its own. */
|
|
22
|
+
message: string;
|
|
23
|
+
/** The source of truth: a JSON consumer reads `records: 1240`, not text. */
|
|
24
|
+
fields?: Record<string, unknown>;
|
|
25
|
+
error?: {
|
|
26
|
+
code?: string;
|
|
27
|
+
cause?: string;
|
|
28
|
+
stack?: string;
|
|
29
|
+
exitCode?: number;
|
|
30
|
+
};
|
|
31
|
+
/** A next move for a person. */
|
|
32
|
+
hint?: {
|
|
33
|
+
action: string;
|
|
34
|
+
when?: string;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A `guide` event's body: a section of a walkthrough a person follows,
|
|
38
|
+
* titled by `message`. `text` is a few plain sentences; `steps` are what to
|
|
39
|
+
* do, in order.
|
|
40
|
+
*/
|
|
41
|
+
guide?: {
|
|
42
|
+
text?: string[];
|
|
43
|
+
steps?: string[];
|
|
44
|
+
};
|
|
45
|
+
/** Names of `fields` that carry personal data. */
|
|
46
|
+
sensitive?: string[];
|
|
47
|
+
/** Events sharing a key aggregate: the first few print, the rest are counted. */
|
|
48
|
+
key?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Where events go. Level filtering, aggregation, and redaction happen here. */
|
|
51
|
+
export interface Sink {
|
|
52
|
+
emit(event: OutputEvent): void;
|
|
53
|
+
/** Write anything held back (aggregated counts) and take down live views. */
|
|
54
|
+
flush?(): void;
|
|
6
55
|
}
|
|
7
56
|
export interface LoggerOptions {
|
|
57
|
+
/** `[ServiceName]` or a bare name; becomes the scope. */
|
|
8
58
|
prefix?: string;
|
|
59
|
+
scope?: string;
|
|
9
60
|
level?: LogLevel;
|
|
10
|
-
theme?: LoggerTheme;
|
|
11
61
|
quiet?: boolean;
|
|
12
62
|
verbose?: boolean;
|
|
63
|
+
/** The host's sink. Without one, the logger writes plain lines to stderr. */
|
|
64
|
+
sink?: Sink;
|
|
65
|
+
run?: RunContext;
|
|
66
|
+
/** Mark every field personal, as for plugin loggers. */
|
|
67
|
+
sensitive?: boolean;
|
|
13
68
|
}
|
|
14
|
-
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
15
69
|
export interface LoggerContext {
|
|
16
70
|
[key: string]: any;
|
|
17
71
|
}
|
|
72
|
+
/** What a `progress` event carries in its fields: a run as it goes. */
|
|
73
|
+
export type ProgressFields = {
|
|
74
|
+
/** `shell · history`: what the run reads. */
|
|
75
|
+
title: string;
|
|
76
|
+
/**
|
|
77
|
+
* `reading` while the source is read in ahead of the run (`processed`
|
|
78
|
+
* counts records read), `loading` once records go through to the output,
|
|
79
|
+
* `writing` while the output flushes at the end.
|
|
80
|
+
*/
|
|
81
|
+
phase?: 'reading' | 'loading' | 'writing';
|
|
82
|
+
processed: number;
|
|
83
|
+
/** Records expected, when the source knows; 0 when it doesn't. */
|
|
84
|
+
total: number;
|
|
85
|
+
/** Records by type so far. */
|
|
86
|
+
counts: Record<string, number>;
|
|
87
|
+
/** The record in hand, as a one-line label. Personal. */
|
|
88
|
+
current?: string;
|
|
89
|
+
/** Where a `writing` run's output goes. */
|
|
90
|
+
target?: string;
|
|
91
|
+
elapsedMs: number;
|
|
92
|
+
};
|
|
93
|
+
/** What a `summary` event carries in its fields: a finished run's totals. */
|
|
94
|
+
export type SummaryFields = {
|
|
95
|
+
/** `shell · history`: what the run read. */
|
|
96
|
+
title: string;
|
|
97
|
+
/** Records read, by type, in the order first seen. */
|
|
98
|
+
counts: Record<string, number>;
|
|
99
|
+
records: number;
|
|
100
|
+
/** What reached the output, when that differs from records read less skipped: a fan-out, or failures. */
|
|
101
|
+
written?: number;
|
|
102
|
+
skipped?: number;
|
|
103
|
+
failed?: number;
|
|
104
|
+
durationMs: number;
|
|
105
|
+
/** Where the records went, when that's a file. */
|
|
106
|
+
output?: string;
|
|
107
|
+
};
|
|
18
108
|
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/logging",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -30,9 +30,6 @@
|
|
|
30
30
|
"access": "public",
|
|
31
31
|
"registry": "https://registry.npmjs.org/"
|
|
32
32
|
},
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"chalk": "^4.1.2"
|
|
35
|
-
},
|
|
36
33
|
"engines": {
|
|
37
34
|
"node": ">=22.13.0"
|
|
38
35
|
},
|
|
@@ -43,7 +40,7 @@
|
|
|
43
40
|
}
|
|
44
41
|
},
|
|
45
42
|
"devDependencies": {
|
|
46
|
-
"@chronicle.app/tsconfig": "0.
|
|
43
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
47
44
|
"@types/node": "^22.10.1",
|
|
48
45
|
"typescript": "^5.7.2"
|
|
49
46
|
}
|
package/dist/DefaultTheme.d.ts
DELETED
package/dist/DefaultTheme.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
// Enhanced theme with colored icons/timestamps only
|
|
3
|
-
export const defaultTheme = {
|
|
4
|
-
debug: msg => `${chalk.gray('⚪')} ${msg}`,
|
|
5
|
-
info: msg => `${chalk.blue('ℹ')} ${msg}`,
|
|
6
|
-
warn: msg => `${chalk.yellow('⚠')} ${msg}`,
|
|
7
|
-
error: msg => `${chalk.red('✖')} ${msg}`,
|
|
8
|
-
};
|