diff --git a/src/logger/ILogger.ts b/src/logger/ILogger.ts new file mode 100644 index 0000000..4da21f0 --- /dev/null +++ b/src/logger/ILogger.ts @@ -0,0 +1,7 @@ +export interface ILogger { + info(message: string, ...optionalParams: unknown[]): void; + debug(message: string, ...optionalParams: unknown[]): void; + warn(message: string, ...optionalParams: unknown[]): void; + error(message: string, ...optionalParams: unknown[]): void; + trace(message: string, ...optionalParams: unknown[]): void; +} \ No newline at end of file diff --git a/src/logger/Logger.ts b/src/logger/Logger.ts new file mode 100644 index 0000000..af0ff87 --- /dev/null +++ b/src/logger/Logger.ts @@ -0,0 +1,102 @@ +import type { ILogger } from './ILogger'; +import { LoggingLevel } from './LoggingLevel'; +import type { Threshold } from './Threshold'; +import type { IAppender } from './appenders/IAppender'; +import LoggingLevelMapping from './LoggingLevelMapping'; + +export default class Logger implements ILogger { + private readonly _category: string; + private readonly _threshold: Threshold; + private readonly _appenders: Set; + constructor(category: string, threshold: Threshold, appenders: Set) { + 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); + } + } +} diff --git a/src/logger/LoggerFactory.ts b/src/logger/LoggerFactory.ts new file mode 100644 index 0000000..c2cdd88 --- /dev/null +++ b/src/logger/LoggerFactory.ts @@ -0,0 +1,43 @@ +import type { IAppender } from "./appenders/IAppender"; +import Logger from './Logger'; +import type { LoggingLevelType } from "./LoggingLevel"; +import LoggingLevelMapping from "./LoggingLevelMapping"; +import { Threshold } from "./Threshold"; +import ConsoleAppender from "./appenders/ConsoleAppender"; +import Disposable from "../lang/disposables/Disposable"; + +type Category = string; + +export default class LoggerFactory { + private static readonly _appenders: Set = new Set(); + private static readonly _threshold: Threshold = new Threshold(); + private static readonly _loggers: Map = new Map(); + + static { + this.applyConsoleAppender(); + } + + 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 applyAppender(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.applyAppender(new ConsoleAppender()); + } +} diff --git a/src/logger/LoggingLevel.ts b/src/logger/LoggingLevel.ts new file mode 100644 index 0000000..0988a85 --- /dev/null +++ b/src/logger/LoggingLevel.ts @@ -0,0 +1,12 @@ +export enum LoggingLevel { + Off = -1, + Debug = 10, + Trace = 20, + Silly = 30, + Info = 40, + Warn = 50, + Error = 60, + All = 70 +} + +export type LoggingLevelType = 'Off' | 'Debug' | 'Trace' | 'Info' | 'Silly' | 'Warn' | 'Error' | 'All'; \ No newline at end of file diff --git a/src/logger/LoggingLevelMapping.ts b/src/logger/LoggingLevelMapping.ts new file mode 100644 index 0000000..fd37c2e --- /dev/null +++ b/src/logger/LoggingLevelMapping.ts @@ -0,0 +1,52 @@ + +import {assertUnreachable} from '@techniker-me/tools'; +import {LoggingLevel, type 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); + } + } +} \ No newline at end of file diff --git a/src/logger/Threshold.ts b/src/logger/Threshold.ts new file mode 100644 index 0000000..7e8f707 --- /dev/null +++ b/src/logger/Threshold.ts @@ -0,0 +1,19 @@ +import Defaults from '../Defaults'; +import { Subject } from '../lang/observables'; +import { LoggingLevel } from './LoggingLevel'; + +export class Threshold { + private _threshold: Subject; + + 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; + } +} diff --git a/src/logger/appenders/Appender.ts b/src/logger/appenders/Appender.ts new file mode 100644 index 0000000..a3d1f84 --- /dev/null +++ b/src/logger/appenders/Appender.ts @@ -0,0 +1,63 @@ +import {LoggingLevel, type LoggingLevelType} from '../LoggingLevel'; +import type ILogMessage from './LogMessage'; + +export type AppenderOptions = { + domain?: string; +}; + +export default class Appender { + private readonly _logRecorderUrl: string = 'https://logserver.techniker.me/api/logs'; + // @ts-ignore + private readonly _domain: string = typeof globalThis !== 'undefined' ? (globalThis.location?.hostname ?? '') : ''; + private readonly _logMessageQueue: ILogMessage[] = []; + private _pendingPostLogMessagePromise: Promise | undefined = undefined; + + constructor(logRecorderUrl: string, {domain}: AppenderOptions) { + this._logRecorderUrl = logRecorderUrl; + this._domain = domain ?? this._domain; + } + + 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 { + 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: ILogMessage): void { + this._logMessageQueue.push(logMessage); + } +} diff --git a/src/logger/appenders/AppenderFactory.ts b/src/logger/appenders/AppenderFactory.ts new file mode 100644 index 0000000..3e3b680 --- /dev/null +++ b/src/logger/appenders/AppenderFactory.ts @@ -0,0 +1,12 @@ +import Appender from './Appender'; +import type { AppenderOptions } from './Appender'; + +export default class AppnederFactory { + public static createRemoteAppender(remoteAppenderUrl: string, {domain}: AppenderOptions): Appender { + return new Appender(remoteAppenderUrl, {domain: domain ?? ''}); + } + + private constructor() { + throw new Error('AppenderFactory is a static class that may not be instantiated'); + } +} diff --git a/src/logger/appenders/ConsoleAppender.ts b/src/logger/appenders/ConsoleAppender.ts new file mode 100644 index 0000000..9b52cb8 --- /dev/null +++ b/src/logger/appenders/ConsoleAppender.ts @@ -0,0 +1,31 @@ +import {LoggingLevel, type LoggingLevelType} from '../LoggingLevel'; +import LoggingLevelMapping from '../LoggingLevelMapping'; +import type {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); + } + } +} diff --git a/src/logger/appenders/IAppender.ts b/src/logger/appenders/IAppender.ts new file mode 100644 index 0000000..379abd2 --- /dev/null +++ b/src/logger/appenders/IAppender.ts @@ -0,0 +1,5 @@ +import type { LoggingLevelType } from '../LoggingLevel'; + +export interface IAppender { + log(timestamp: string, level: LoggingLevelType, category: string, message: string): void; +} diff --git a/src/logger/appenders/LogMessage.ts b/src/logger/appenders/LogMessage.ts new file mode 100644 index 0000000..4652e72 --- /dev/null +++ b/src/logger/appenders/LogMessage.ts @@ -0,0 +1,6 @@ +export default interface ILogMessage { + timestamp: string; + level: string; + category: string; + message: string; +} diff --git a/src/logger/appenders/TechnikerMeAppender.ts b/src/logger/appenders/TechnikerMeAppender.ts new file mode 100644 index 0000000..a5f115e --- /dev/null +++ b/src/logger/appenders/TechnikerMeAppender.ts @@ -0,0 +1,55 @@ +import type {LoggingLevel, LoggingLevelType} from '../LoggingLevel'; +import type {IAppender} from './IAppender'; +import type ILogMessage from './LogMessage'; + +export default class TechnikerMeAppender implements IAppender { + private readonly _logRecorderUrl: string = 'https://logserver.techniker.me/api/logs'; + // @ts-ignore + private readonly _domain: string = typeof globalThis !== 'undefined' ? (globalThis.location?.hostname ?? '') : ''; + private readonly _logMessageQueue: ILogMessage[] = []; + private _pendingPostLogMessagePromise: Promise | 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 { + 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: ILogMessage): void { + this._logMessageQueue.push(logMessage); + } +}