@c9up/spectrum 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/Logger.d.ts +34 -0
- package/dist/Logger.d.ts.map +1 -0
- package/dist/Logger.js +72 -0
- package/dist/Logger.js.map +1 -0
- package/dist/RustLogBridge.d.ts +22 -0
- package/dist/RustLogBridge.d.ts.map +1 -0
- package/dist/RustLogBridge.js +124 -0
- package/dist/RustLogBridge.js.map +1 -0
- package/dist/SpectrumProvider.d.ts +19 -0
- package/dist/SpectrumProvider.d.ts.map +1 -0
- package/dist/SpectrumProvider.js +35 -0
- package/dist/SpectrumProvider.js.map +1 -0
- package/dist/channels/ConsoleChannel.d.ts +13 -0
- package/dist/channels/ConsoleChannel.d.ts.map +1 -0
- package/dist/channels/ConsoleChannel.js +89 -0
- package/dist/channels/ConsoleChannel.js.map +1 -0
- package/dist/channels/FileChannel.d.ts +23 -0
- package/dist/channels/FileChannel.d.ts.map +1 -0
- package/dist/channels/FileChannel.js +150 -0
- package/dist/channels/FileChannel.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +10 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +11 -0
- package/dist/configure.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/services/main.d.ts +18 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +31 -0
- package/dist/services/main.js.map +1 -0
- package/dist/testing/FakeLogger.d.ts +28 -0
- package/dist/testing/FakeLogger.d.ts.map +1 -0
- package/dist/testing/FakeLogger.js +111 -0
- package/dist/testing/FakeLogger.js.map +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -0
- package/src/Logger.ts +99 -0
- package/src/RustLogBridge.ts +142 -0
- package/src/SpectrumProvider.ts +52 -0
- package/src/channels/ConsoleChannel.ts +100 -0
- package/src/channels/FileChannel.ts +170 -0
- package/src/config.ts +7 -0
- package/src/configure.ts +23 -0
- package/src/index.ts +13 -0
- package/src/services/main.ts +39 -0
- package/src/testing/FakeLogger.ts +140 -0
- package/src/types.ts +36 -0
package/src/Logger.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spectrum Logger — structured logging with levels and correlation ID.
|
|
3
|
+
*
|
|
4
|
+
* @implements FR54, FR55, FR56, FR58
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LogConfig, LogEntry, LogLevel } from "./types.js";
|
|
8
|
+
import { LOG_LEVEL_ORDER } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export type { LogLevel };
|
|
11
|
+
|
|
12
|
+
export class Logger {
|
|
13
|
+
private config: LogConfig;
|
|
14
|
+
private module: string;
|
|
15
|
+
private correlationId?: string;
|
|
16
|
+
|
|
17
|
+
constructor(config: LogConfig, module = "app", correlationId?: string) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.module = module;
|
|
20
|
+
this.correlationId = correlationId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a child logger scoped to a module and/or correlation ID.
|
|
25
|
+
* This is the preferred way to set correlation ID — creates an immutable copy.
|
|
26
|
+
*/
|
|
27
|
+
child(options: { module?: string; correlationId?: string }): Logger {
|
|
28
|
+
return new Logger(
|
|
29
|
+
this.config,
|
|
30
|
+
options.module ?? this.module,
|
|
31
|
+
options.correlationId ?? this.correlationId,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set the correlation ID on THIS instance.
|
|
37
|
+
* Prefer child() for per-request scoping to avoid shared-state mutation.
|
|
38
|
+
*/
|
|
39
|
+
setCorrelationId(id: string): void {
|
|
40
|
+
this.correlationId = id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
trace(message: string, data?: Record<string, unknown>): void {
|
|
44
|
+
this.log("trace", message, data);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
debug(message: string, data?: Record<string, unknown>): void {
|
|
48
|
+
this.log("debug", message, data);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
info(message: string, data?: Record<string, unknown>): void {
|
|
52
|
+
this.log("info", message, data);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
warn(message: string, data?: Record<string, unknown>): void {
|
|
56
|
+
this.log("warn", message, data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
error(message: string, data?: Record<string, unknown>): void {
|
|
60
|
+
this.log("error", message, data);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fatal(message: string, data?: Record<string, unknown>): void {
|
|
64
|
+
this.log("fatal", message, data);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private log(
|
|
68
|
+
level: LogLevel,
|
|
69
|
+
message: string,
|
|
70
|
+
data?: Record<string, unknown>,
|
|
71
|
+
): void {
|
|
72
|
+
const rawEffective =
|
|
73
|
+
this.config.modules?.[this.module] ?? this.config.level;
|
|
74
|
+
const effectiveLevel: LogLevel =
|
|
75
|
+
LOG_LEVEL_ORDER[rawEffective] !== undefined ? rawEffective : "info";
|
|
76
|
+
if ((LOG_LEVEL_ORDER[level] ?? 0) < LOG_LEVEL_ORDER[effectiveLevel]) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const entry: LogEntry = {
|
|
81
|
+
level,
|
|
82
|
+
message,
|
|
83
|
+
module: this.module,
|
|
84
|
+
correlationId: this.correlationId,
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
data,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (const channel of this.config.channels) {
|
|
90
|
+
try {
|
|
91
|
+
channel.write(entry);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
process.stderr.write(
|
|
94
|
+
`[Spectrum] Channel '${channel.name}' failed for: ${message} — ${String(err)}\n`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rust Log Bridge — unifies Rust stderr output with Spectrum log entries.
|
|
3
|
+
*
|
|
4
|
+
* Captures Rust log output (which goes to stderr) and re-emits it
|
|
5
|
+
* through the Spectrum Logger so both Rust and TS logs appear in
|
|
6
|
+
* the same stream with the same format.
|
|
7
|
+
*
|
|
8
|
+
* @implements FR55
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LogChannel, LogEntry, LogLevel } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a Rust log line into a Spectrum LogEntry.
|
|
15
|
+
* Supports common Rust log formats:
|
|
16
|
+
* [INFO ream_http] Server listening on 0.0.0.0:3000
|
|
17
|
+
* [WARN ream_bus] Slow dispatch: 5ms
|
|
18
|
+
*/
|
|
19
|
+
export function parseRustLog(line: string): LogEntry | null {
|
|
20
|
+
// Pattern: [LEVEL module] message
|
|
21
|
+
const match = line.match(/^\[(\w+)\s+(\S+)\]\s+(.+)$/);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
|
|
24
|
+
const levelMap: Record<string, LogLevel> = {
|
|
25
|
+
TRACE: "trace",
|
|
26
|
+
DEBUG: "debug",
|
|
27
|
+
INFO: "info",
|
|
28
|
+
WARN: "warn",
|
|
29
|
+
ERROR: "error",
|
|
30
|
+
FATAL: "fatal",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const level = levelMap[match[1].toUpperCase()];
|
|
34
|
+
if (!level) return null;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
level,
|
|
38
|
+
message: match[3],
|
|
39
|
+
module: match[2].replace(/_/g, "-"),
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a bridge that captures stderr and routes Rust logs to Spectrum channels.
|
|
46
|
+
*
|
|
47
|
+
* Usage:
|
|
48
|
+
* const bridge = createRustLogBridge(logger.config.channels)
|
|
49
|
+
* bridge.start()
|
|
50
|
+
* // ... Rust crates emit to stderr
|
|
51
|
+
* bridge.stop()
|
|
52
|
+
*/
|
|
53
|
+
let activeOriginalWrite: typeof process.stderr.write | undefined;
|
|
54
|
+
let activeBridge: { stop: () => void } | undefined;
|
|
55
|
+
|
|
56
|
+
export function createRustLogBridge(channels: LogChannel[]): {
|
|
57
|
+
start: () => void;
|
|
58
|
+
stop: () => void;
|
|
59
|
+
} {
|
|
60
|
+
let bridgingDepth = 0;
|
|
61
|
+
|
|
62
|
+
const bridge = {
|
|
63
|
+
start() {
|
|
64
|
+
if (activeBridge === bridge) return;
|
|
65
|
+
if (activeBridge) {
|
|
66
|
+
const prev = activeOriginalWrite;
|
|
67
|
+
activeBridge.stop();
|
|
68
|
+
if (prev) {
|
|
69
|
+
process.stderr.write = prev as typeof process.stderr.write;
|
|
70
|
+
prev(
|
|
71
|
+
"[Spectrum] RustLogBridge replaced — previous bridge stopped. Only one bridge can be active per process.\n" as never,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
activeOriginalWrite = process.stderr.write.bind(process.stderr);
|
|
77
|
+
activeBridge = bridge;
|
|
78
|
+
|
|
79
|
+
process.stderr.write = ((
|
|
80
|
+
chunk: string | Uint8Array,
|
|
81
|
+
...args: unknown[]
|
|
82
|
+
) => {
|
|
83
|
+
const origWrite = activeOriginalWrite;
|
|
84
|
+
if (!origWrite) return true;
|
|
85
|
+
if (bridgingDepth > 0) {
|
|
86
|
+
return origWrite(chunk as never, ...(args as []));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const str =
|
|
90
|
+
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
91
|
+
let hadRustLog = false;
|
|
92
|
+
const passthroughLines: string[] = [];
|
|
93
|
+
|
|
94
|
+
// Try to parse as Rust log — if it matches, route to channels
|
|
95
|
+
for (const line of str.split("\n")) {
|
|
96
|
+
const trimmed = line.trim();
|
|
97
|
+
if (!trimmed) continue;
|
|
98
|
+
|
|
99
|
+
const entry = parseRustLog(trimmed);
|
|
100
|
+
if (entry) {
|
|
101
|
+
hadRustLog = true;
|
|
102
|
+
for (const channel of channels) {
|
|
103
|
+
bridgingDepth++;
|
|
104
|
+
try {
|
|
105
|
+
channel.write(entry);
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
} finally {
|
|
109
|
+
bridgingDepth--;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
passthroughLines.push(line);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Preserve original chunk when we didn't intercept any Rust logs.
|
|
118
|
+
if (!hadRustLog) {
|
|
119
|
+
return origWrite(chunk as never, ...(args as []));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const line of passthroughLines) {
|
|
123
|
+
origWrite(`${line}\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return true;
|
|
127
|
+
}) as typeof process.stderr.write;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
stop() {
|
|
131
|
+
if (activeBridge !== bridge) return;
|
|
132
|
+
if (activeOriginalWrite) {
|
|
133
|
+
process.stderr.write =
|
|
134
|
+
activeOriginalWrite as typeof process.stderr.write;
|
|
135
|
+
activeOriginalWrite = undefined;
|
|
136
|
+
}
|
|
137
|
+
activeBridge = undefined;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return bridge;
|
|
142
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ConsoleChannel } from "./channels/ConsoleChannel.js";
|
|
2
|
+
import { Logger } from "./Logger.js";
|
|
3
|
+
import { setLogger } from "./services/main.js";
|
|
4
|
+
import type { LogLevel } from "./types.js";
|
|
5
|
+
import { LOG_LEVEL_ORDER } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const VALID_LEVELS = new Set<string>(Object.keys(LOG_LEVEL_ORDER));
|
|
8
|
+
|
|
9
|
+
function resolveLogLevel(raw: string | undefined): LogLevel {
|
|
10
|
+
if (raw && VALID_LEVELS.has(raw)) return raw as LogLevel;
|
|
11
|
+
return "info";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SpectrumContainer {
|
|
15
|
+
singleton(token: unknown, factory: () => unknown): void;
|
|
16
|
+
resolve<T = unknown>(token: unknown): T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SpectrumConfigStore {
|
|
20
|
+
get<T = unknown>(key: string): T | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SpectrumAppContext {
|
|
24
|
+
container: SpectrumContainer;
|
|
25
|
+
config: SpectrumConfigStore;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default class SpectrumProvider {
|
|
29
|
+
constructor(protected app: SpectrumAppContext) {}
|
|
30
|
+
|
|
31
|
+
register() {
|
|
32
|
+
this.app.container.singleton(Logger, () => {
|
|
33
|
+
const config = this.app.config.get<{ level?: LogLevel }>("logger");
|
|
34
|
+
const level =
|
|
35
|
+
config?.level && VALID_LEVELS.has(config.level)
|
|
36
|
+
? config.level
|
|
37
|
+
: resolveLogLevel(process.env.LOG_LEVEL);
|
|
38
|
+
return new Logger({
|
|
39
|
+
level,
|
|
40
|
+
channels: [new ConsoleChannel("pretty")],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.app.container.singleton("logger", () => {
|
|
45
|
+
return this.app.container.resolve<Logger>(Logger);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async boot() {
|
|
50
|
+
setLogger(this.app.container.resolve<Logger>(Logger));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console log channel — pretty-print in dev, JSON in prod.
|
|
3
|
+
*
|
|
4
|
+
* @implements FR57, FR58
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LogChannel, LogEntry } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export class ConsoleChannel implements LogChannel {
|
|
10
|
+
name = "console";
|
|
11
|
+
#format: "pretty" | "json";
|
|
12
|
+
|
|
13
|
+
constructor(format: "pretty" | "json" = "pretty") {
|
|
14
|
+
this.#format = format;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
write(entry: LogEntry): void {
|
|
18
|
+
if (this.#format === "json") {
|
|
19
|
+
this.#writeJson(entry);
|
|
20
|
+
} else {
|
|
21
|
+
this.#writePretty(entry);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#writeJson(entry: LogEntry): void {
|
|
26
|
+
// Data nested under 'data' key — no spread to prevent key collisions
|
|
27
|
+
const output = JSON.stringify({
|
|
28
|
+
timestamp: entry.timestamp,
|
|
29
|
+
level: entry.level,
|
|
30
|
+
module: entry.module,
|
|
31
|
+
message: entry.message,
|
|
32
|
+
correlationId: entry.correlationId,
|
|
33
|
+
data: entry.data,
|
|
34
|
+
});
|
|
35
|
+
this.#writeToStream(entry.level, `${output}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#sanitize(str: string): string {
|
|
39
|
+
// Strip ANSI escape sequences. ESC (0x1B) is a control char so we match
|
|
40
|
+
// it via String.fromCharCode rather than a /\x1b/ regex (Biome's
|
|
41
|
+
// noControlCharactersInRegex rule rightly flags the literal form).
|
|
42
|
+
const ESC = String.fromCharCode(0x1b);
|
|
43
|
+
return str
|
|
44
|
+
.replace(/\r/g, "\\r")
|
|
45
|
+
.replace(/\n/g, "\\n")
|
|
46
|
+
.replaceAll(ESC, "[ESC]");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#writePretty(entry: LogEntry): void {
|
|
50
|
+
const time = entry.timestamp.substring(11, 19); // HH:MM:SS
|
|
51
|
+
const levelStr = entry.level.toUpperCase().padEnd(5);
|
|
52
|
+
const prefix = this.#levelPrefix(entry.level);
|
|
53
|
+
// Sanitize every interpolated piece — `module` is usually
|
|
54
|
+
// developer-controlled but `correlationId` typically flows in from
|
|
55
|
+
// an HTTP header (X-Request-Id / X-Correlation-Id) and can carry
|
|
56
|
+
// attacker-supplied CRLF that would otherwise forge fake log lines.
|
|
57
|
+
const cidRaw = entry.correlationId
|
|
58
|
+
? entry.correlationId.length > 8
|
|
59
|
+
? `${entry.correlationId.substring(0, 8)}…`
|
|
60
|
+
: entry.correlationId
|
|
61
|
+
: "";
|
|
62
|
+
const cid = cidRaw ? ` cid=${this.#sanitize(cidRaw)}` : "";
|
|
63
|
+
const dataStr = entry.data ? ` ${JSON.stringify(entry.data)}` : "";
|
|
64
|
+
const message = this.#sanitize(entry.message);
|
|
65
|
+
const module = this.#sanitize(entry.module);
|
|
66
|
+
|
|
67
|
+
this.#writeToStream(
|
|
68
|
+
entry.level,
|
|
69
|
+
`${prefix} ${time} ${levelStr} [${module}] ${message}${cid}${dataStr}\n`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Route error/fatal to stderr, others to stdout. */
|
|
74
|
+
#writeToStream(level: string, output: string): void {
|
|
75
|
+
if (level === "error" || level === "fatal") {
|
|
76
|
+
process.stderr.write(output);
|
|
77
|
+
} else {
|
|
78
|
+
process.stdout.write(output);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#levelPrefix(level: string): string {
|
|
83
|
+
switch (level) {
|
|
84
|
+
case "trace":
|
|
85
|
+
return " ";
|
|
86
|
+
case "debug":
|
|
87
|
+
return " ";
|
|
88
|
+
case "info":
|
|
89
|
+
return "i";
|
|
90
|
+
case "warn":
|
|
91
|
+
return "!";
|
|
92
|
+
case "error":
|
|
93
|
+
return "x";
|
|
94
|
+
case "fatal":
|
|
95
|
+
return "X";
|
|
96
|
+
default:
|
|
97
|
+
return " ";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File logging channel — writes log entries to a file as JSON lines.
|
|
3
|
+
*
|
|
4
|
+
* Uses a non-blocking WriteStream with an internal buffer. Rotation is
|
|
5
|
+
* scheduled via queueMicrotask to avoid blocking the event loop on the
|
|
6
|
+
* HTTP hot path.
|
|
7
|
+
*
|
|
8
|
+
* @implements MISS-14, MISS-15
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as fsp from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import type { LogChannel, LogEntry } from "../types.js";
|
|
15
|
+
|
|
16
|
+
export interface FileChannelConfig {
|
|
17
|
+
path: string;
|
|
18
|
+
maxSizeBytes?: number; // default 10MB
|
|
19
|
+
maxFiles?: number; // default 5
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class FileChannel implements LogChannel {
|
|
23
|
+
name = "file";
|
|
24
|
+
#filePath: string;
|
|
25
|
+
#maxSize: number;
|
|
26
|
+
#maxFiles: number;
|
|
27
|
+
#stream: fs.WriteStream | null = null;
|
|
28
|
+
#currentSize = 0;
|
|
29
|
+
#dirReady = false;
|
|
30
|
+
#rotating = false;
|
|
31
|
+
#pending: string[] = [];
|
|
32
|
+
|
|
33
|
+
constructor(config: FileChannelConfig) {
|
|
34
|
+
this.#filePath = config.path;
|
|
35
|
+
this.#maxSize = config.maxSizeBytes ?? 10 * 1024 * 1024;
|
|
36
|
+
this.#maxFiles = config.maxFiles ?? 5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
write(entry: LogEntry): void {
|
|
40
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
41
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
42
|
+
|
|
43
|
+
if (this.#rotating) {
|
|
44
|
+
this.#pending.push(line);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!this.#stream) {
|
|
49
|
+
try {
|
|
50
|
+
this.#openStream();
|
|
51
|
+
} catch {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`[Spectrum] FileChannel: failed to open '${this.#filePath}'\n`,
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.#currentSize + bytes > this.#maxSize) {
|
|
60
|
+
this.#pending.push(line);
|
|
61
|
+
this.#scheduleRotation();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.#stream) {
|
|
66
|
+
this.#stream.write(line);
|
|
67
|
+
this.#currentSize += bytes;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#openStream(): void {
|
|
72
|
+
// mkdirSync + statSync only on first open (boot) — never on the hot path.
|
|
73
|
+
// The fast write path goes straight through this.#stream.write() (non-blocking).
|
|
74
|
+
if (!this.#dirReady) {
|
|
75
|
+
const dir = path.dirname(this.#filePath);
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
this.#dirReady = true;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
this.#currentSize = fs.statSync(this.#filePath).size;
|
|
81
|
+
} catch {
|
|
82
|
+
this.#currentSize = 0;
|
|
83
|
+
}
|
|
84
|
+
this.#stream = fs.createWriteStream(this.#filePath, { flags: "a" });
|
|
85
|
+
this.#stream.on("error", (err) => {
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`[Spectrum] FileChannel stream error: ${err.message}\n`,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#scheduleRotation(): void {
|
|
93
|
+
if (this.#rotating) return;
|
|
94
|
+
this.#rotating = true;
|
|
95
|
+
queueMicrotask(() => {
|
|
96
|
+
this.#rotate().finally(() => {
|
|
97
|
+
this.#rotating = false;
|
|
98
|
+
this.#flushPending();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async #rotate(): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
await this.#closeStream();
|
|
106
|
+
|
|
107
|
+
const oldest = `${this.#filePath}.${this.#maxFiles - 1}`;
|
|
108
|
+
await fsp.rm(oldest, { force: true });
|
|
109
|
+
|
|
110
|
+
for (let i = this.#maxFiles - 2; i >= 1; i--) {
|
|
111
|
+
const from = i === 1 ? this.#filePath : `${this.#filePath}.${i}`;
|
|
112
|
+
const to = `${this.#filePath}.${i + 1}`;
|
|
113
|
+
try {
|
|
114
|
+
await fsp.rename(from, to);
|
|
115
|
+
} catch {
|
|
116
|
+
// File doesn't exist — skip
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await fsp.rename(this.#filePath, `${this.#filePath}.1`);
|
|
122
|
+
} catch {
|
|
123
|
+
// Nothing to rotate
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.#openStream();
|
|
127
|
+
} catch {
|
|
128
|
+
try {
|
|
129
|
+
this.#openStream();
|
|
130
|
+
} catch {
|
|
131
|
+
process.stderr.write(
|
|
132
|
+
`[Spectrum] FileChannel: rotation failed, logging suspended for '${this.#filePath}'\n`,
|
|
133
|
+
);
|
|
134
|
+
this.#stream = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#flushPending(): void {
|
|
140
|
+
if (!this.#stream) return;
|
|
141
|
+
while (this.#pending.length > 0) {
|
|
142
|
+
const line = this.#pending.shift();
|
|
143
|
+
if (!line) continue;
|
|
144
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
145
|
+
if (this.#currentSize + bytes > this.#maxSize) {
|
|
146
|
+
this.#pending.unshift(line);
|
|
147
|
+
this.#scheduleRotation();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.#stream.write(line);
|
|
151
|
+
this.#currentSize += bytes;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async #closeStream(): Promise<void> {
|
|
156
|
+
if (!this.#stream) return;
|
|
157
|
+
const stream = this.#stream;
|
|
158
|
+
this.#stream = null;
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
stream.end(() => resolve());
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
close(): void {
|
|
165
|
+
if (this.#stream) {
|
|
166
|
+
this.#stream.end();
|
|
167
|
+
this.#stream = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/config.ts
ADDED
package/src/configure.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface Codemods {
|
|
2
|
+
addProvider(importPath: string): Promise<void>;
|
|
3
|
+
addEnvVars(vars: Record<string, string>): Promise<void>;
|
|
4
|
+
writeFile(
|
|
5
|
+
filePath: string,
|
|
6
|
+
content: string,
|
|
7
|
+
options?: { force?: boolean },
|
|
8
|
+
): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function configure(codemods: Codemods): Promise<void> {
|
|
12
|
+
await codemods.addProvider("@c9up/spectrum/provider");
|
|
13
|
+
await codemods.writeFile(
|
|
14
|
+
"config/logger.ts",
|
|
15
|
+
`import { defineConfig } from '@c9up/spectrum'
|
|
16
|
+
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
level: process.env.LOG_LEVEL ?? 'info',
|
|
19
|
+
channels: ['console'],
|
|
20
|
+
})
|
|
21
|
+
`,
|
|
22
|
+
);
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @c9up/spectrum
|
|
3
|
+
* @description Spectrum — structured logging for the Ream framework
|
|
4
|
+
* @implements FR54, FR55, FR56, FR57, FR58
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { ConsoleChannel } from "./channels/ConsoleChannel.js";
|
|
8
|
+
export { FileChannel } from "./channels/FileChannel.js";
|
|
9
|
+
export { defineConfig } from "./config.js";
|
|
10
|
+
export { configure } from "./configure.js";
|
|
11
|
+
export { Logger, type LogLevel } from "./Logger.js";
|
|
12
|
+
export { createRustLogBridge, parseRustLog } from "./RustLogBridge.js";
|
|
13
|
+
export type { LogChannel, LogConfig, LogEntry } from "./types.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `Logger` singleton — mirror of Adonis's
|
|
3
|
+
* `import logger from '@adonisjs/core/services/logger'` shape.
|
|
4
|
+
*
|
|
5
|
+
* import logger from '@c9up/spectrum/services/main'
|
|
6
|
+
*
|
|
7
|
+
* logger.info({ userId }, 'user logged in')
|
|
8
|
+
*
|
|
9
|
+
* Populated by `SpectrumProvider.boot()`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Logger } from "../Logger.js";
|
|
13
|
+
|
|
14
|
+
let instance: Logger | undefined;
|
|
15
|
+
|
|
16
|
+
/** @internal Bind the singleton (called by SpectrumProvider). */
|
|
17
|
+
export function setLogger(value: Logger): void {
|
|
18
|
+
instance = value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
22
|
+
export function getLogger(): Logger | undefined {
|
|
23
|
+
return instance;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const logger: Logger = new Proxy({} as Logger, {
|
|
27
|
+
get(_target, prop) {
|
|
28
|
+
if (!instance) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"[spectrum] Logger singleton accessed before SpectrumProvider.boot() ran. " +
|
|
31
|
+
"Check that `@c9up/spectrum/provider` is listed in your reamrc.ts providers.",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const value = Reflect.get(instance, prop, instance);
|
|
35
|
+
return typeof value === "function" ? value.bind(instance) : value;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export default logger;
|