Initial Commit

This commit is contained in:
2025-08-16 15:12:58 -04:00
commit ea8fd991a0
25 changed files with 656 additions and 0 deletions

11
src/Defaults.ts Normal file
View File

@@ -0,0 +1,11 @@
import {LoggingLevel} from './level/LoggingLevel';
export default class Defaults {
static get loggingLevel(): LoggingLevel {
return LoggingLevel.Info;
}
private constructor() {
throw new Error('Defaults is a static class that may not be instantiated');
}
}

8
src/ILogger.ts Normal file
View File

@@ -0,0 +1,8 @@
export default interface ILogger {
info(message: string, ...optionalParameters: unknown[]): void;
warn(message: string, ...optionalParameters: unknown[]): void;
error(message: string, ...optionalParameters: unknown[]): void;
debug(message: string, ...optionalParameters: unknown[]): void;
trace(message: string, ...optionalParameters: unknown[]): void;
silly(message: string, ...optionalParameters: unknown[]): void;
}

102
src/Logger.ts Normal file
View File

@@ -0,0 +1,102 @@
import IAppender from './appenders/IAppender';
import {LoggingLevel} from './level/LoggingLevel';
import LoggingLevelMapping from './level/LoggingLevelMapping';
import Threshold from './level/Threshold';
export default class Logger {
private readonly _category: string;
private readonly _threshold: Threshold;
private readonly _appenders: Set<IAppender>;
constructor(category: string, threshold: Threshold, appenders: Set<IAppender>) {
this._category = category;
this._threshold = threshold;
this._appenders = appenders;
}
public info(message: string, ...optionalParameters: unknown[]): void {
if (this._threshold.value > LoggingLevel.Info) {
return;
}
this.log(LoggingLevel.Info, message, ...optionalParameters);
}
public warn(message: string, ...optionalParameters: unknown[]): void {
if (this._threshold.value > LoggingLevel.Warn) {
return;
}
this.log(LoggingLevel.Warn, message, ...optionalParameters);
}
public error(message: string, ...optionalParameters: unknown[]): void {
if (this._threshold.value > LoggingLevel.Error) {
return;
}
this.log(LoggingLevel.Error, message, ...optionalParameters);
}
public debug(message: string, ...optionalParameters: unknown[]): void {
if (this._threshold.value > LoggingLevel.Debug) {
return;
}
this.log(LoggingLevel.Debug, message, ...optionalParameters);
}
public trace(message: string, ...optionalParameters: unknown[]): void {
if (this._threshold.value > LoggingLevel.Trace) {
return;
}
this.log(LoggingLevel.Trace, message, ...optionalParameters);
}
public silly(message: string, ...optionalParameters: unknown[]): void {
if (this._threshold.value > LoggingLevel.Silly) {
return;
}
this.log(LoggingLevel.Silly, message, ...optionalParameters);
}
private formatMessage(message: string, ...optionalParameters: unknown[]): string {
let optionalParameterIndex = 0;
return message.replace(/%[sdj]/g, match => {
if (optionalParameterIndex >= optionalParameters.length) {
return match;
}
const param = optionalParameters[optionalParameterIndex++];
switch (match) {
case '%s':
return String(param);
case '%d':
return typeof param === 'number' ? param.toString() : 'NaN';
case '%j':
try {
return JSON.stringify(param);
} catch {
return '[Circular]';
}
default:
return match;
}
});
}
private log(loggingLevel: LoggingLevel, message: string, ...optionalParameters: unknown[]): void {
const timestamp = new Date().toISOString();
const formattedMessage = this.formatMessage(message, ...optionalParameters);
const level = LoggingLevelMapping.convertLoggingLevelToLoggingLevelType(loggingLevel);
for (const appender of this._appenders) {
appender.log(timestamp, level, this._category, formattedMessage);
}
}
}

49
src/LoggerFactory.ts Normal file
View File

@@ -0,0 +1,49 @@
import {Disposable} from '@techniker-me/tools';
import ConsoleAppender from './appenders/ConsoleAppender';
import IAppender from './appenders/IAppender';
import Threshold from './level/Threshold';
import Logger from './Logger';
import {LoggingLevelType} from './level/LoggingLevel';
import LoggingLevelMapping from './level/LoggingLevelMapping';
import TechnikerMeAppender from './appenders/TechnikerMeAppender';
type Category = string;
export default class LoggerFactory {
private static readonly _appenders: Set<IAppender> = new Set();
private static readonly _threshold: Threshold = new Threshold();
private static readonly _loggers: Map<Category, Logger> = new Map();
static {
this.applyConsoleAppender();
this.applyRemoteAppender();
}
public static getLogger(category: string): Logger {
if (!LoggerFactory._loggers.has(category)) {
this._loggers.set(category, new Logger(category, LoggerFactory._threshold, LoggerFactory._appenders));
}
return LoggerFactory._loggers.get(category) as Logger;
}
public static applyApppender(appender: IAppender): Disposable {
LoggerFactory._appenders.add(appender);
return new Disposable(() => LoggerFactory._appenders.delete(appender));
}
public static setLoggingLevel(loggingLevelType: LoggingLevelType): void {
const loggingLevel = LoggingLevelMapping.convertLoggingLevelTypeToLoggingLevel(loggingLevelType);
LoggerFactory._threshold.value = loggingLevel;
}
private static applyConsoleAppender(): void {
LoggerFactory.applyApppender(new ConsoleAppender());
}
private static applyRemoteAppender(): void {
LoggerFactory.applyApppender(new TechnikerMeAppender());
}
}

View File

@@ -0,0 +1,31 @@
import {LoggingLevel, LoggingLevelType} from '../level/LoggingLevel';
import LoggingLevelMapping from '../level/LoggingLevelMapping';
import IAppender from './IAppender';
import {assertUnreachable} from '@techniker-me/tools';
export default class ConsoleAppender implements IAppender {
public log(timestamp: string, level: LoggingLevelType, category: string, message: string) {
const loggingLevel = LoggingLevelMapping.convertLoggingLevelTypeToLoggingLevel(level);
switch (loggingLevel) {
case LoggingLevel.Off:
break;
case LoggingLevel.Warn:
case LoggingLevel.Error:
console.error(`${timestamp} [${level}] [${category}] ${message}`);
break;
case LoggingLevel.Info:
case LoggingLevel.Debug:
case LoggingLevel.Trace:
case LoggingLevel.Silly:
case LoggingLevel.All:
console.log(`${timestamp} [${level}] [${category}] ${message}`);
break;
default:
assertUnreachable(loggingLevel);
}
}
}

View File

@@ -0,0 +1,5 @@
import {LoggingLevelType} from '../level/LoggingLevel';
export default interface IAppender {
log(timestamp: string, level: LoggingLevelType, category: string, message: string): void;
}

View File

@@ -0,0 +1,60 @@
import type {LoggingLevelType} from '../level/LoggingLevel';
import type IAppender from './IAppender';
type LogMessage = {
timestamp: string;
level: string;
category: string;
message: string;
};
export default class TechnikerMeAppender implements IAppender {
private readonly _logRecorderUrl: string = 'https://logserver.techniker.me/api/logs';
private readonly _domain: string = typeof window !== 'undefined' ? (window.location?.hostname ?? '') : '';
private readonly _logMessageQueue: LogMessage[] = [];
private _pendingPostLogMessagePromise: Promise<Response | undefined> | undefined = undefined;
public log(timestamp: string, level: LoggingLevelType, category: string, message: string): void {
const logMessage = {
timestamp,
domain: this._domain,
level,
category,
message
};
this.queueMessage(logMessage);
this.postLogMessage();
}
private async postLogMessage(): Promise<void> {
const logMessage = this._logMessageQueue.shift();
if (!logMessage || this._pendingPostLogMessagePromise !== undefined) {
return;
}
try {
if (typeof fetch === 'undefined') {
console.error('Fetch API is not available in this environment');
return;
}
this._pendingPostLogMessagePromise = fetch(this._logRecorderUrl, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
mode: 'no-cors',
method: 'POST',
body: JSON.stringify(logMessage)
}).then(() => (this._pendingPostLogMessagePromise = undefined));
} catch (e) {
console.error('Unable to send logs due to [%o]', e);
return;
}
}
private queueMessage(logMessage: LogMessage): void {
this._logMessageQueue.push(logMessage);
}
}

8
src/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import type ILogger from './ILogger';
import type IAppender from './appenders/IAppender';
import LoggerFactory from './LoggerFactory';
import LoggingLevelMapping from './level/LoggingLevelMapping';
export type {ILogger, IAppender};
export {LoggerFactory, LoggingLevelMapping};
export default {LoggerFactory, LoggingLevelMapping};

12
src/level/LoggingLevel.ts Normal file
View File

@@ -0,0 +1,12 @@
export enum LoggingLevel {
Off = -1,
Info = 10,
Warn = 20,
Error = 30,
Debug = 40,
Trace = 50,
Silly = 60,
All = 100
}
export type LoggingLevelType = 'Off' | 'Info' | 'Warn' | 'Error' | 'Debug' | 'Trace' | 'Silly' | 'All';

View File

@@ -0,0 +1,51 @@
import {assertUnreachable} from '@techniker-me/tools';
import {LoggingLevel, LoggingLevelType} from './LoggingLevel';
export default class LoggingLevelMapping {
public static convertLoggingLevelToLoggingLevelType(loggingLevel: LoggingLevel): LoggingLevelType {
switch (loggingLevel) {
case LoggingLevel.Off:
return 'Off';
case LoggingLevel.Info:
return 'Info';
case LoggingLevel.Warn:
return 'Warn';
case LoggingLevel.Error:
return 'Error';
case LoggingLevel.Debug:
return 'Debug';
case LoggingLevel.Trace:
return 'Trace';
case LoggingLevel.Silly:
return 'Silly';
case LoggingLevel.All:
return 'All';
default:
assertUnreachable(loggingLevel);
}
}
public static convertLoggingLevelTypeToLoggingLevel(loggingLevelType: LoggingLevelType): LoggingLevel {
switch (loggingLevelType) {
case 'Off':
return LoggingLevel.Off;
case 'Info':
return LoggingLevel.Info;
case 'Warn':
return LoggingLevel.Warn;
case 'Error':
return LoggingLevel.Error;
case 'Debug':
return LoggingLevel.Debug;
case 'Trace':
return LoggingLevel.Trace;
case 'Silly':
return LoggingLevel.Silly;
case 'All':
return LoggingLevel.All;
default:
assertUnreachable(loggingLevelType);
}
}
}

21
src/level/Threshold.ts Normal file
View File

@@ -0,0 +1,21 @@
import {Subject} from '@techniker-me/tools';
import Defaults from '../Defaults';
import {LoggingLevel} from './LoggingLevel';
class Threshold {
private _threshold: Subject<LoggingLevel> = new Subject(LoggingLevel.Debug);
constructor(loggingLevel?: LoggingLevel) {
this._threshold = new Subject(loggingLevel ?? Defaults.loggingLevel);
}
set value(value: LoggingLevel) {
this._threshold.value = value;
}
get value(): LoggingLevel {
return this._threshold.value;
}
}
export default Threshold;