@chronicle.app/logging 0.2.0 → 0.4.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 CHANGED
@@ -1,22 +1,65 @@
1
1
  # @chronicle.app/logging
2
2
 
3
- Shared stderr logger for Chronicle. Node.js 22.13+; ESM with TypeScript declarations.
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({ prefix: '[extract]', verbose: true });
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
- All output goes to stderr so stdout stays available for extracted data. `quiet`
14
- suppresses everything except errors. Debug messages require `verbose`; `level`
15
- filters info/warn output. `verboseInfo` requires verbose and non-quiet mode. Errors
16
- always print. Context can be rendered inline or with `debugMultiline`. A custom
17
- `theme` supplies functions for each level; default colors are disabled for pipes.
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
- `npm run build -w @chronicle.app/logging` builds the package; `npm test -w
20
- @chronicle.app/logging` exercises its public API after a build.
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, LoggerContext } from './types.js';
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 prefix;
4
- private level;
5
- private quiet;
6
- private verbose;
7
- private theme;
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
- private shouldLog;
10
- private formatPrefix;
11
- private formatMessage;
12
- private formatCompactJson;
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 { defaultTheme } from './DefaultTheme.js';
2
- import chalk from 'chalk';
3
- const LOG_LEVELS = {
4
- debug: 0,
5
- info: 1,
6
- warn: 2,
7
- error: 3,
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
- prefix;
11
- level;
12
- quiet;
13
- verbose;
14
- theme;
13
+ scope;
14
+ sink;
15
+ fallback;
16
+ run;
17
+ sensitive;
18
+ options;
15
19
  constructor(options = {}) {
16
- this.prefix = options.prefix ? `${options.prefix} ` : '';
17
- this.level = options.level ?? 'info';
18
- this.quiet = options.quiet ?? false;
19
- this.verbose = options.verbose ?? false;
20
- this.theme = options.theme ?? defaultTheme;
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
- shouldLog(level) {
30
- if (level === 'error')
31
- return true; // Always show errors
32
- if (this.quiet)
33
- return false;
34
- if (level === 'debug')
35
- return this.verbose; // Debug messages only in verbose mode
36
- return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
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
- formatPrefix(serviceName) {
39
- // Extract service name from prefix like '[ServiceName]' or just use as-is
40
- const match = serviceName.match(/\[([^\]]+)\]/);
41
- const name = match ? match[1] : serviceName.replaceAll(/^\s*|\s*$/g, '');
42
- // Fixed width of 25 characters, left-aligned
43
- return name.padEnd(25);
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
- formatMessage(message, context, multiline = false) {
46
- const service = this.prefix ? this.formatPrefix(this.prefix) : ''.padEnd(25);
47
- let formatted = `${service} ${message}`;
48
- if (context && Object.keys(context).length > 0) {
49
- if (multiline) {
50
- // Custom JSON formatting: compact arrays, multiline objects
51
- const compactJson = this.formatCompactJson(context);
52
- formatted += `\n${compactJson}`;
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
- formatCompactJson(obj, depth = 0) {
61
- const indent = ' '.repeat(depth);
62
- const nextIndent = ' '.repeat(depth + 1);
63
- if (Array.isArray(obj)) {
64
- // Keep arrays on single line for compactness
65
- return JSON.stringify(obj);
66
- }
67
- if (obj && typeof obj === 'object') {
68
- const entries = Object.entries(obj);
69
- if (entries.length === 0)
70
- return '{}';
71
- const formatted = entries
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
- if (this.shouldLog('debug')) {
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
- if (this.shouldLog('debug')) {
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
- if (this.shouldLog('info')) {
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
- if (!this.quiet && this.verbose) {
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
- if (this.shouldLog('warn')) {
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
- const formatted = this.formatMessage(message, context);
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
@@ -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;
@@ -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 { defaultTheme } from './DefaultTheme.js';
3
- export type { LoggerTheme, LoggerOptions, LogLevel, LoggerContext } from './types.js';
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 { defaultTheme } from './DefaultTheme.js';
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
@@ -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
+ }
@@ -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 interface LoggerTheme {
2
- info: (message: string) => string;
3
- warn: (message: string) => string;
4
- error: (message: string) => string;
5
- debug: (message: string) => string;
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.2.0",
3
+ "version": "0.4.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -10,8 +10,7 @@
10
10
  "scripts": {
11
11
  "build": "tsc -b tsconfig.lib.json",
12
12
  "typecheck": "tsc -p tsconfig.json",
13
- "lint": "eslint src --ext .ts,.js",
14
- "test": "node --test src/*.test.js"
13
+ "lint": "eslint src --ext .ts,.js"
15
14
  },
16
15
  "keywords": [],
17
16
  "author": "Andrew Louis",
@@ -31,9 +30,6 @@
31
30
  "access": "public",
32
31
  "registry": "https://registry.npmjs.org/"
33
32
  },
34
- "dependencies": {
35
- "chalk": "^4.1.2"
36
- },
37
33
  "engines": {
38
34
  "node": ">=22.13.0"
39
35
  },
@@ -44,7 +40,7 @@
44
40
  }
45
41
  },
46
42
  "devDependencies": {
47
- "@chronicle.app/tsconfig": "0.2.0",
43
+ "@chronicle.app/tsconfig": "0.4.0",
48
44
  "@types/node": "^22.10.1",
49
45
  "typescript": "^5.7.2"
50
46
  }
@@ -1,3 +0,0 @@
1
- import { LoggerTheme } from './types.js';
2
- export declare const defaultTheme: LoggerTheme;
3
- //# sourceMappingURL=DefaultTheme.d.ts.map
@@ -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
- };