Add logging framework with Logger, LoggerFactory, and appenders
- Introduced ILogger interface for logging methods - Implemented Logger class with various logging levels and message formatting - Created LoggerFactory for managing logger instances and appender configuration - Added LoggingLevel enum and mapping for logging level types - Developed ConsoleAppender and TechnikerMeAppender for different logging outputs - Implemented appender management with AppenderFactory and base Appender class - Established Threshold class for controlling logging levels
This commit is contained in:
7
src/logger/ILogger.ts
Normal file
7
src/logger/ILogger.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
102
src/logger/Logger.ts
Normal file
102
src/logger/Logger.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/logger/LoggerFactory.ts
Normal file
43
src/logger/LoggerFactory.ts
Normal file
@@ -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<IAppender> = new Set();
|
||||||
|
private static readonly _threshold: Threshold = new Threshold();
|
||||||
|
private static readonly _loggers: Map<Category, Logger> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/logger/LoggingLevel.ts
Normal file
12
src/logger/LoggingLevel.ts
Normal file
@@ -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';
|
||||||
52
src/logger/LoggingLevelMapping.ts
Normal file
52
src/logger/LoggingLevelMapping.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/logger/Threshold.ts
Normal file
19
src/logger/Threshold.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Defaults from '../Defaults';
|
||||||
|
import { Subject } from '../lang/observables';
|
||||||
|
import { LoggingLevel } from './LoggingLevel';
|
||||||
|
|
||||||
|
export class Threshold {
|
||||||
|
private _threshold: Subject<LoggingLevel>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/logger/appenders/Appender.ts
Normal file
63
src/logger/appenders/Appender.ts
Normal file
@@ -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<Response | undefined> | 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<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: ILogMessage): void {
|
||||||
|
this._logMessageQueue.push(logMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/logger/appenders/AppenderFactory.ts
Normal file
12
src/logger/appenders/AppenderFactory.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/logger/appenders/ConsoleAppender.ts
Normal file
31
src/logger/appenders/ConsoleAppender.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/logger/appenders/IAppender.ts
Normal file
5
src/logger/appenders/IAppender.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { LoggingLevelType } from '../LoggingLevel';
|
||||||
|
|
||||||
|
export interface IAppender {
|
||||||
|
log(timestamp: string, level: LoggingLevelType, category: string, message: string): void;
|
||||||
|
}
|
||||||
6
src/logger/appenders/LogMessage.ts
Normal file
6
src/logger/appenders/LogMessage.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default interface ILogMessage {
|
||||||
|
timestamp: string;
|
||||||
|
level: string;
|
||||||
|
category: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
55
src/logger/appenders/TechnikerMeAppender.ts
Normal file
55
src/logger/appenders/TechnikerMeAppender.ts
Normal file
@@ -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<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: ILogMessage): void {
|
||||||
|
this._logMessageQueue.push(logMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user