feat(logging): implement structured logging system with ccStructLog

This commit is contained in:
2025-11-20 20:00:39 +08:00
parent a4e74dcfa0
commit 6d5cf11f2b
16 changed files with 1644 additions and 344 deletions

View File

@@ -0,0 +1,165 @@
/**
* Main Logger class implementation.
* This is the primary entry point for users to interact with the logging system.
*/
import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
/**
* The main Logger class that orchestrates the logging pipeline.
*
* This class takes log messages, creates LogEvent objects, processes them through
* a chain of processors, renders them to strings, and outputs them via streams.
*/
export class Logger implements ILogger {
private options: LoggerOptions;
/**
* Create a new Logger instance.
*
* @param options - Configuration options for the logger
*/
constructor(options: Partial<LoggerOptions>) {
this.options = {
processors: options.processors ?? [],
renderer: options.renderer ?? this.defaultRenderer,
streams: options.streams ?? [],
};
}
/**
* Default renderer that returns an empty string.
* Used as fallback when no renderer is provided.
*/
private defaultRenderer = (): string => "";
/**
* Main logging method that handles the complete logging pipeline.
*
* @param level - The log level
* @param message - The log message
* @param context - Additional context data as key-value pairs
*/
public log(
level: LogLevel,
message: string,
context: Record<string, unknown> = {},
): void {
// 1. Create initial LogEvent with core fields
let event: LogEvent | undefined = new Map<string, unknown>([
["level", level],
["message", message],
...Object.entries(context),
]);
// 2. Process through the processor chain
for (const processor of this.options.processors) {
if (event === undefined) {
break; // Event was dropped by a processor
}
event = processor(event);
}
// 3. Render and output if event wasn't dropped
if (event !== undefined) {
const finalEvent = event;
const output = this.options.renderer(finalEvent);
// Send to all configured streams
for (const stream of this.options.streams) {
stream.write(output, finalEvent);
}
}
}
/**
* Log a trace message.
* Typically used for very detailed diagnostic information.
*
* @param message - The log message
* @param context - Additional context data
*/
public trace(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Trace, message, context);
}
/**
* Log a debug message.
* Used for diagnostic information useful during development.
*
* @param message - The log message
* @param context - Additional context data
*/
public debug(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Debug, message, context);
}
/**
* Log an info message.
* Used for general informational messages about application flow.
*
* @param message - The log message
* @param context - Additional context data
*/
public info(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Info, message, context);
}
/**
* Log a warning message.
* Used for potentially harmful situations that don't stop execution.
*
* @param message - The log message
* @param context - Additional context data
*/
public warn(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Warn, message, context);
}
/**
* Log an error message.
* Used for error events that might allow the application to continue.
*
* @param message - The log message
* @param context - Additional context data
*/
public error(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Error, message, context);
}
/**
* Log a fatal message.
* Used for very severe error events that might cause termination.
*
* @param message - The log message
* @param context - Additional context data
*/
public fatal(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.Fatal, message, context);
}
/**
* Update the logger's configuration.
* Useful for dynamically changing logging behavior at runtime.
*
* @param options - New configuration options to merge with existing ones
*/
public configure(options: Partial<LoggerOptions>): void {
this.options = {
...this.options,
...options,
};
}
/**
* Close all streams and clean up resources.
* Should be called when the logger is no longer needed.
*/
public close(): void {
for (const stream of this.options.streams) {
if (stream.close) {
stream.close();
}
}
}
}

View File

@@ -0,0 +1,156 @@
/**
* Main entry point for the ccStructLog library.
*
* This module provides convenient factory functions and pre-configured
* logger instances for common use cases. It exports all the core components
* while providing easy-to-use defaults for typical logging scenarios.
*/
// Re-export all core types and classes
export {
LogLevel,
LogEvent,
Processor,
Renderer,
Stream,
LoggerOptions,
ILogger,
} from "./types";
export { Logger } from "./Logger";
// Re-export all processors
export {
addTimestamp,
addFormattedTimestamp,
addFullTimestamp,
filterByLevel,
addSource,
addComputerId,
addComputerLabel,
filterBy,
transformField,
removeFields,
addStaticFields,
} from "./processors";
// Re-export all renderers
export { jsonRenderer, textRenderer } from "./renderers";
// Re-export all streams
export {
ConsoleStream,
FileStream,
BufferStream,
NullStream,
SECOND,
MINUTE,
HOUR,
DAY,
WEEK,
} from "./streams";
import { Logger } from "./Logger";
import { LogLevel, LogEvent } from "./types";
import {
addFormattedTimestamp,
addFullTimestamp,
addComputerId,
} from "./processors";
import { textRenderer, jsonRenderer } from "./renderers";
import { ConsoleStream, FileStream, DAY } from "./streams";
/**
* Create a development logger with console output and colored formatting.
*
* This logger is optimized for development and debugging, with:
* - Debug level and above
* - Formatted timestamps
* - Computer ID tracking
* - Human-readable console output with colors
*
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for development
*/
export function createDevLogger(
options: {
level?: LogLevel;
source?: string;
includeComputerId?: boolean;
} = {},
): Logger {
const processors = [addFormattedTimestamp()];
if (options.includeComputerId !== false) {
processors.push(addComputerId());
}
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
return new Logger({
processors,
renderer: textRenderer,
streams: [new ConsoleStream()],
});
}
/**
* Create a production logger with file output and JSON formatting.
*
* This logger is optimized for production environments, with:
* - Info level and above
* - Full timestamps
* - Computer ID and label tracking
* - JSON output for machine processing
* - Daily file rotation
*
* @param filename - Base filename for log files
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for production
*/
export function createProdLogger(
filename: string,
options: {
level?: LogLevel;
source?: string;
rotationInterval?: number;
includeConsole?: boolean;
} = {},
): Logger {
const processors = [
addFullTimestamp(),
addComputerId(),
(event: LogEvent) => {
const label = os.getComputerLabel();
if (label) {
event.set("computer_label", label);
}
return event;
},
];
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
const streams: Array<ConsoleStream | FileStream> = [
new FileStream(filename, options.rotationInterval ?? DAY),
];
if (options.includeConsole) {
streams.push(new ConsoleStream());
}
return new Logger({
processors,
renderer: jsonRenderer,
streams,
});
}

View File

@@ -0,0 +1,220 @@
/**
* Standard processors for the ccStructLog library.
*
* Processors are functions that can modify, enrich, or filter log events
* as they flow through the logging pipeline. Each processor receives a
* LogEvent and can return a modified LogEvent or undefined to drop the log.
*/
import { LogEvent, Processor, LogLevel } from "./types";
/**
* Adds a timestamp to the log event.
*
* This processor adds the current time as a structured timestamp object
* using CC:Tweaked's os.date() function. The timestamp includes year,
* month, day, hour, minute, and second components.
*
* Performance note: os.date() is relatively expensive, so this should
* typically be placed early in the processor chain and used only once.
*
* @param event - The log event to process
* @returns The event with timestamp added
*/
export function addTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
event.set("timestamp", timestamp);
return event;
};
}
/**
* Adds a human-readable timestamp string to the log event.
*
* This processor adds a formatted timestamp string that's easier to read
* in log output. The format is "HH:MM:SS" in UTC time.
*
* @param event - The log event to process
* @returns The event with formatted timestamp added
*/
export function addFormattedTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const timeStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("time", timeStr);
return event;
};
}
/**
* Adds a full ISO-like timestamp string to the log event.
*
* This processor adds a complete timestamp in YYYY-MM-DD HH:MM:SS format
* which is useful for file logging and structured output.
*
* @param event - The log event to process
* @returns The event with full timestamp added
*/
export function addFullTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const fullTimeStr = `${timestamp.year}-${string.format("%02d", timestamp.month)}-${string.format("%02d", timestamp.day)} ${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("datetime", fullTimeStr);
return event;
};
}
/**
* Filters log events by minimum level.
*
* This processor drops log events that are below the specified minimum level.
* Note: The Logger class already does early filtering for performance, but
* this processor can be useful for dynamic filtering or when you need
* different levels for different streams.
*
* @param minLevel - The minimum log level to allow through
* @returns A processor function that filters by level
*/
export function filterByLevel(minLevel: LogLevel): Processor {
return (event) => {
const eventLevel = event.get("level") as LogLevel | undefined;
if (eventLevel === undefined) {
return event; // Pass through if no level is set
}
if (eventLevel !== undefined && eventLevel < minLevel) {
return undefined; // Drop the log event
}
return event;
};
}
/**
* Adds a logger name/source to the log event.
*
* This processor is useful when you have multiple loggers in your application
* and want to identify which component generated each log entry.
*
* @param name - The name/source to add to log events
* @returns A processor function that adds the source name
*/
export function addSource(name: string): Processor {
return (event) => {
event.set("source", name);
return event;
};
}
/**
* Adds the current computer ID to the log event.
*
* In CC:Tweaked environments, this can help identify which computer
* generated the log when logs are aggregated from multiple sources.
*
* @param event - The log event to process
* @returns The event with computer ID added
*/
export function addComputerId(): Processor {
return (event) => {
event.set("computer_id", os.getComputerID());
return event;
};
}
/**
* Adds the current computer label to the log event.
*
* If the computer has a label set, this adds it to the log event.
* This can be more human-readable than the computer ID.
*
* @param event - The log event to process
* @returns The event with computer label added (if available)
*/
export function addComputerLabel(): Processor {
return (event) => {
const label = os.getComputerLabel();
if (label !== undefined && label !== null) {
event.set("computer_label", label);
}
return event;
};
}
/**
* Filters out events that match a specific condition.
*
* This is a generic processor that allows you to filter events based on
* any custom condition. The predicate function should return true to keep
* the event and false to drop it.
*
* @param predicate - Function that returns true to keep the event
* @returns A processor function that filters based on the predicate
*/
export function filterBy(predicate: (event: LogEvent) => boolean): Processor {
return (event) => {
return predicate(event) ? event : undefined;
};
}
/**
* Transforms a specific field in the log event.
*
* This processor allows you to modify the value of a specific field
* using a transformation function.
*
* @param fieldName - The name of the field to transform
* @param transformer - Function to transform the field value
* @returns A processor function that transforms the specified field
*/
export function transformField(
fieldName: string,
transformer: (value: unknown) => unknown,
): Processor {
return (event) => {
if (event.has(fieldName)) {
const currentValue = event.get(fieldName);
const newValue = transformer(currentValue);
event.set(fieldName, newValue);
}
return event;
};
}
/**
* Removes specified fields from the log event.
*
* This processor can be used to strip sensitive or unnecessary information
* from log events before they are rendered and output.
*
* @param fieldNames - Array of field names to remove
* @returns A processor function that removes the specified fields
*/
export function removeFields(fieldNames: string[]): Processor {
return (event) => {
for (const fieldName of fieldNames) {
event.delete(fieldName);
}
return event;
};
}
/**
* Adds static fields to every log event.
*
* This processor adds the same set of fields to every log event that
* passes through it. Useful for adding application name, version,
* environment, etc.
*
* @param fields - Object containing the static fields to add
* @returns A processor function that adds the static fields
*/
export function addStaticFields(fields: Record<string, unknown>): Processor {
return (event) => {
for (const [key, value] of Object.entries(fields)) {
event.set(key, value);
}
return event;
};
}

View File

@@ -0,0 +1,87 @@
/**
* Standard renderers for the ccStructLog library.
*
* Renderers are functions that convert processed LogEvent objects into
* their final string representation. Different renderers can produce
* different output formats (JSON, console-friendly, etc.).
*/
import { Renderer } from "./types";
/**
* Renders log events as JSON strings.
*
* This renderer converts the LogEvent Map into a plain object and then
* serializes it as JSON. This format is ideal for structured logging
* and machine processing.
*
* Note: This assumes textutils.serialiseJSON is available (CC:Tweaked).
* Falls back to a simple key=value format if JSON serialization fails.
*
* @param event - The log event to render
* @returns JSON string representation of the event
*/
export const jsonRenderer: Renderer = (event) => {
try {
// Convert Map to plain object for JSON serialization
const obj: Record<string, unknown> = {};
for (const [key, value] of event.entries()) {
obj[key] = value;
}
// Use CC:Tweaked's JSON serialization if available
return textutils.serialiseJSON(obj);
} catch (error) {
return String(error);
}
};
/**
* Renders log events in a human-readable Text format.
*
* This renderer creates output suitable for terminal display, with
* timestamp, level, message, and additional context fields formatted
* in a readable way.
*
* Format: [HH:MM:SS] [LEVEL] message { key=value, key2=value2 }
*
* @param event - The log event to render
* @returns Human-readable string representation
*/
export const textRenderer: Renderer = (event) => {
// Extract core components
const timestamp = event.get("timestamp") as LuaDate | undefined;
const timeStr = event.get("time") as string | undefined;
const level = (event.get("level") as string)?.toUpperCase() ?? "UNKNOWN";
const message = (event.get("message") as string) ?? "";
// Format timestamp
let timestampStr = "";
if (timeStr) {
timestampStr = timeStr;
} else if (timestamp) {
timestampStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
}
// Start building the output
let output = `[${timestampStr}] [${level}] ${message}`;
// Add context fields (excluding the core fields we already used)
const contextFields: string[] = [];
for (const [key, value] of event.entries()) {
if (
key !== "timestamp" &&
key !== "time" &&
key !== "level" &&
key !== "message"
) {
contextFields.push(`${key}=${tostring(value)}`);
}
}
if (contextFields.length > 0) {
output += ` { ${contextFields.join(", ")} }`;
}
return output;
};

View File

@@ -0,0 +1,308 @@
/**
* Standard output streams for the ccStructLog library.
*
* Streams are responsible for writing the final formatted log messages
* to their destination (console, file, network, etc.). Each stream
* implements the Stream interface and handles its own output logic.
*/
import { Stream, LogEvent } from "./types";
/**
* Console stream that outputs to the CC:Tweaked terminal.
*
* This stream writes log messages to the computer's terminal with
* color coding based on log levels. It preserves the original text
* color after writing each message.
*/
export class ConsoleStream implements Stream {
private levelColors: Map<string, number> = new Map([
["trace", colors.lightGray],
["debug", colors.gray],
["info", colors.white],
["warn", colors.orange],
["error", colors.red],
["fatal", colors.red],
]);
/**
* Write a formatted log message to the terminal.
*
* @param message - The formatted log message
* @param event - The original log event for context (used for level-based coloring)
*/
public write(message: string, event: LogEvent): void {
const level = event.get("level") as string | undefined;
const color = level ? this.levelColors.get(level) : undefined;
if (color !== undefined) {
const originalColor = term.getTextColor();
term.setTextColor(color);
print(message);
term.setTextColor(originalColor);
} else {
print(message);
}
}
}
/**
* File stream that outputs to a file on disk.
*
* This stream writes log messages to a specified file, creating the file
* if it doesn't exist and appending to it if it does. It handles file
* rotation based on time intervals.
*/
export class FileStream implements Stream {
private fileHandle: LuaFile | undefined;
private filePath: string;
private rotationInterval: number;
private lastRotationTime: number;
private baseFilename: string;
/**
* Create a new file stream.
*
* @param filePath - Path to the log file
* @param rotationInterval - Time in seconds between file rotations (0 = no rotation)
*/
constructor(filePath: string, rotationInterval: number = 0) {
this.filePath = filePath;
this.rotationInterval = rotationInterval;
this.lastRotationTime = os.time();
this.baseFilename = filePath;
this.openFile();
}
/**
* Open the log file for writing.
* Creates the file if it doesn't exist, appends if it does.
*/
private openFile(): void {
const actualPath =
this.rotationInterval > 0
? this.getRotatedFilename()
: this.filePath;
const [handle, err] = io.open(actualPath, "a");
if (handle === undefined) {
printError(
`Failed to open log file ${actualPath}: ${err ?? "Unknown error"}`,
);
return;
}
this.fileHandle = handle;
}
/**
* Generate a filename with timestamp for file rotation.
*/
private getRotatedFilename(): string {
const currentTime = os.time();
const rotationPeriod =
Math.floor(currentTime / this.rotationInterval) *
this.rotationInterval;
const date = os.date("*t", rotationPeriod) as LuaDate;
const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}_${string.format("%02d", date.hour)}-${string.format("%02d", date.min)}`;
// Split filename and extension
const splitStrs = this.baseFilename.split(".");
if (splitStrs.length === 1) {
return `${this.baseFilename}_${timestamp}.log`;
}
const name = splitStrs[0];
const ext = splitStrs[1];
return `${name}_${timestamp}.${ext}`;
}
/**
* Check if file rotation is needed and rotate if necessary.
*/
private checkRotation(): void {
if (this.rotationInterval <= 0) return;
const currentTime = os.time();
const currentPeriod = Math.floor(currentTime / this.rotationInterval);
const lastPeriod = Math.floor(
this.lastRotationTime / this.rotationInterval,
);
if (currentPeriod > lastPeriod) {
// Time to rotate
this.close();
this.lastRotationTime = currentTime;
this.openFile();
}
}
/**
* Write a formatted log message to the file.
*
* @param message - The formatted log message
* @param event - The original log event (unused in this implementation)
*/
public write(message: string, event: LogEvent): void {
this.checkRotation();
if (this.fileHandle) {
this.fileHandle.write(message + "\n");
this.fileHandle.flush();
}
}
/**
* Close the file handle and release resources.
*/
public close(): void {
if (this.fileHandle) {
this.fileHandle.close();
this.fileHandle = undefined;
}
}
}
/**
* Buffer stream that collects log messages in memory.
*
* This stream stores log messages in an internal buffer, which can be
* useful for testing, temporary storage, or implementing custom output
* logic that processes multiple messages at once.
*/
export class BufferStream implements Stream {
private buffer: string[] = [];
private maxSize: number;
/**
* Create a new buffer stream.
*
* @param maxSize - Maximum number of messages to store (0 = unlimited)
*/
constructor(maxSize: number = 0) {
this.maxSize = maxSize;
}
/**
* Write a formatted log message to the buffer.
*
* @param message - The formatted log message
* @param event - The original log event (unused in this implementation)
*/
public write(message: string, event: LogEvent): void {
this.buffer.push(message);
// Trim buffer if it exceeds max size
if (this.maxSize > 0 && this.buffer.length > this.maxSize) {
this.buffer.shift();
}
}
/**
* Get all buffered messages.
*
* @returns Array of all buffered log messages
*/
public getMessages(): string[] {
return [...this.buffer];
}
/**
* Get and clear all buffered messages.
*
* @returns Array of all buffered log messages
*/
public flush(): string[] {
const messages = [...this.buffer];
this.buffer = [];
return messages;
}
/**
* Clear the buffer without returning messages.
*/
public clear(): void {
this.buffer = [];
}
/**
* Get the current number of buffered messages.
*
* @returns Number of messages in the buffer
*/
public size(): number {
return this.buffer.length;
}
}
/**
* Null stream that discards all log messages.
*
* This stream can be useful for testing or when you want to temporarily
* disable logging output without reconfiguring the entire logger.
*/
export class NullStream implements Stream {
/**
* Discard the log message (do nothing).
*
* @param message - The formatted log message (ignored)
* @param event - The original log event (ignored)
*/
public write(message: string, event: LogEvent): void {
// Intentionally do nothing
}
}
/**
* Conditional stream that only writes messages meeting certain criteria.
*
* This stream wraps another stream and only forwards messages that
* match the specified condition.
*/
export class ConditionalStream implements Stream {
private targetStream: Stream;
private condition: (message: string, event: LogEvent) => boolean;
/**
* Create a new conditional stream.
*
* @param targetStream - The stream to write to when condition is met
* @param condition - Function that returns true to allow writing
*/
constructor(
targetStream: Stream,
condition: (message: string, event: LogEvent) => boolean,
) {
this.targetStream = targetStream;
this.condition = (message, event) => condition(message, event);
}
/**
* Write a formatted log message if the condition is met.
*
* @param message - The formatted log message
* @param event - The original log event
*/
public write(message: string, event: LogEvent): void {
if (this.condition(message, event)) {
this.targetStream.write(message, event);
}
}
/**
* Close the target stream.
*/
public close(): void {
if (this.targetStream.close) {
this.targetStream.close();
}
}
}
// Time constants for file rotation
export const SECOND = 1;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;

View File

@@ -0,0 +1,107 @@
/**
* Core types for the ccStructLog library.
* This module defines the fundamental interfaces and types used throughout the logging system.
*/
/**
* Available log levels in order of severity.
*/
export enum LogLevel {
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
Fatal = 5,
}
/**
* A log event represented as a key-value map.
* Uses Map to maintain insertion order of keys.
*/
export type LogEvent = Map<string, unknown>;
/**
* A processor function that can modify, filter, or enrich log events.
*
* @param event - The log event to process
* @returns The processed log event, or undefined to drop the log
*/
export type Processor = (event: LogEvent) => LogEvent | undefined;
/**
* A renderer function that converts a log event to a string representation.
*
* @param event - The final log event after all processing
* @returns The formatted string representation
*/
export type Renderer = (event: LogEvent) => string;
/**
* Interface for output streams that handle the final log output.
*/
export interface Stream {
/**
* Write a formatted log message to the output destination.
*
* @param message - The formatted log message
* @param event - The original log event for context
*/
write(message: string, event: LogEvent): void;
/**
* Close the stream and release any resources.
* Optional method for cleanup.
*/
close?(): void;
}
/**
* Configuration options for creating a Logger instance.
*/
export interface LoggerOptions {
/** Array of processors to apply to log events */
processors: Processor[];
/** Renderer to format the final log output */
renderer: Renderer;
/** Array of streams to output the formatted logs */
streams: Stream[];
}
/**
* Interface for the main Logger class.
*/
export interface ILogger {
/**
* Log a message at the specified level.
*
* @param level - The log level
* @param message - The log message
* @param context - Additional context data
*/
log(
level: LogLevel,
message: string,
context?: Record<string, unknown>,
): void;
/** Log at trace level */
trace(message: string, context?: Record<string, unknown>): void;
/** Log at debug level */
debug(message: string, context?: Record<string, unknown>): void;
/** Log at info level */
info(message: string, context?: Record<string, unknown>): void;
/** Log at warn level */
warn(message: string, context?: Record<string, unknown>): void;
/** Log at error level */
error(message: string, context?: Record<string, unknown>): void;
/** Log at fatal level */
fatal(message: string, context?: Record<string, unknown>): void;
}