diff --git a/libs/common/src/tools/log/default-semantic-logger.spec.ts b/libs/common/src/tools/log/default-semantic-logger.spec.ts new file mode 100644 index 00000000000..8d1dadb66af --- /dev/null +++ b/libs/common/src/tools/log/default-semantic-logger.spec.ts @@ -0,0 +1,199 @@ +import { mock } from "jest-mock-extended"; + +import { LogService } from "../../platform/abstractions/log.service"; +import { LogLevelType } from "../../platform/enums"; + +import { DefaultSemanticLogger } from "./default-semantic-logger"; + +const logger = mock(); + +describe("DefaultSemanticLogger", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("debug", () => { + it("writes structural log messages to console.log", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.debug("this is a debug message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, { + message: "this is a debug message", + level: LogLevelType.Debug, + }); + }); + + it("writes structural content to console.log", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.debug({ example: "this is content" }); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, { + content: { example: "this is content" }, + level: LogLevelType.Debug, + }); + }); + + it("writes structural content to console.log with a message", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.info({ example: "this is content" }, "this is a message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + content: { example: "this is content" }, + message: "this is a message", + level: LogLevelType.Info, + }); + }); + }); + + describe("info", () => { + it("writes structural log messages to console.log", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.info("this is an info message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + message: "this is an info message", + level: LogLevelType.Info, + }); + }); + + it("writes structural content to console.log", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.info({ example: "this is content" }); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + content: { example: "this is content" }, + level: LogLevelType.Info, + }); + }); + + it("writes structural content to console.log with a message", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.info({ example: "this is content" }, "this is a message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + content: { example: "this is content" }, + message: "this is a message", + level: LogLevelType.Info, + }); + }); + }); + + describe("warn", () => { + it("writes structural log messages to console.warn", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.warn("this is a warning message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { + message: "this is a warning message", + level: LogLevelType.Warning, + }); + }); + + it("writes structural content to console.warn", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.warn({ example: "this is content" }); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { + content: { example: "this is content" }, + level: LogLevelType.Warning, + }); + }); + + it("writes structural content to console.warn with a message", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.warn({ example: "this is content" }, "this is a message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { + content: { example: "this is content" }, + message: "this is a message", + level: LogLevelType.Warning, + }); + }); + }); + + describe("error", () => { + it("writes structural log messages to console.error", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.error("this is an error message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + message: "this is an error message", + level: LogLevelType.Error, + }); + }); + + it("writes structural content to console.error", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.error({ example: "this is content" }); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + content: { example: "this is content" }, + level: LogLevelType.Error, + }); + }); + + it("writes structural content to console.error with a message", () => { + const log = new DefaultSemanticLogger(logger, {}); + + log.error({ example: "this is content" }, "this is a message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + content: { example: "this is content" }, + message: "this is a message", + level: LogLevelType.Error, + }); + }); + }); + + describe("panic", () => { + it("writes structural log messages to console.error before throwing the message", () => { + const log = new DefaultSemanticLogger(logger, {}); + + expect(() => log.panic("this is an error message")).toThrow("this is an error message"); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + message: "this is an error message", + level: LogLevelType.Error, + }); + }); + + it("writes structural log messages to console.error with a message before throwing the message", () => { + const log = new DefaultSemanticLogger(logger, {}); + + expect(() => log.panic({ example: "this is content" }, "this is an error message")).toThrow( + "this is an error message", + ); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + content: { example: "this is content" }, + message: "this is an error message", + level: LogLevelType.Error, + }); + }); + + it("writes structural log messages to console.error with a content before throwing the message", () => { + const log = new DefaultSemanticLogger(logger, {}); + + expect(() => log.panic("this is content", "this is an error message")).toThrow( + "this is an error message", + ); + + expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + content: "this is content", + message: "this is an error message", + level: LogLevelType.Error, + }); + }); + }); +}); diff --git a/libs/common/src/tools/log/default-semantic-logger.ts b/libs/common/src/tools/log/default-semantic-logger.ts new file mode 100644 index 00000000000..1bd62baf126 --- /dev/null +++ b/libs/common/src/tools/log/default-semantic-logger.ts @@ -0,0 +1,65 @@ +import { Jsonify } from "type-fest"; + +import { LogService } from "../../platform/abstractions/log.service"; +import { LogLevelType } from "../../platform/enums"; + +import { SemanticLogger } from "./semantic-logger.abstraction"; + +/** Sends semantic logs to the console. + * @remarks the behavior of this logger is based on `LogService`; it + * replaces dynamic messages (`%s`) with a JSON-formatted semantic log. + */ +export class DefaultSemanticLogger implements SemanticLogger { + /** Instantiates a console semantic logger + * @param context a static payload that is cloned when the logger + * logs a message. The `messages`, `level`, and `content` fields + * are reserved for use by loggers. + */ + constructor( + private logger: LogService, + context: Jsonify, + ) { + this.context = context && typeof context === "object" ? context : {}; + } + + readonly context: object; + + debug(content: Jsonify, message?: string): void { + this.log(content, LogLevelType.Debug, message); + } + + info(content: Jsonify, message?: string): void { + this.log(content, LogLevelType.Info, message); + } + + warn(content: Jsonify, message?: string): void { + this.log(content, LogLevelType.Warning, message); + } + + error(content: Jsonify, message?: string): void { + this.log(content, LogLevelType.Error, message); + } + + panic(content: Jsonify, message?: string): never { + this.log(content, LogLevelType.Error, message); + const panicMessage = + message ?? (typeof content === "string" ? content : "a fatal error occurred"); + throw new Error(panicMessage); + } + + private log(content: Jsonify, level: LogLevelType, message?: string) { + const log = { + ...this.context, + message, + content: content ?? undefined, + level, + }; + + if (typeof content === "string" && !message) { + log.message = content; + delete log.content; + } + + this.logger.write(level, log); + } +} diff --git a/libs/common/src/tools/log/disabled-semantic-logger.ts b/libs/common/src/tools/log/disabled-semantic-logger.ts new file mode 100644 index 00000000000..054c3ed390b --- /dev/null +++ b/libs/common/src/tools/log/disabled-semantic-logger.ts @@ -0,0 +1,18 @@ +import { Jsonify } from "type-fest"; + +import { SemanticLogger } from "./semantic-logger.abstraction"; + +/** Disables semantic logs. Still panics. */ +export class DisabledSemanticLogger implements SemanticLogger { + debug(_content: Jsonify, _message?: string): void {} + + info(_content: Jsonify, _message?: string): void {} + + warn(_content: Jsonify, _message?: string): void {} + + error(_content: Jsonify, _message?: string): void {} + + panic(_content: Jsonify, message?: string): never { + throw new Error(message); + } +} diff --git a/libs/common/src/tools/log/factory.ts b/libs/common/src/tools/log/factory.ts new file mode 100644 index 00000000000..d887c4e310a --- /dev/null +++ b/libs/common/src/tools/log/factory.ts @@ -0,0 +1,30 @@ +import { Jsonify } from "type-fest"; + +import { LogService } from "../../platform/abstractions/log.service"; + +import { DefaultSemanticLogger } from "./default-semantic-logger"; +import { DisabledSemanticLogger } from "./disabled-semantic-logger"; +import { SemanticLogger } from "./semantic-logger.abstraction"; + +/** Instantiates a semantic logger that emits nothing when a message + * is logged. + * @param _context a static payload that is cloned when the logger + * logs a message. The `messages`, `level`, and `content` fields + * are reserved for use by loggers. + */ +export function disabledSemanticLoggerProvider( + _context: Jsonify, +): SemanticLogger { + return new DisabledSemanticLogger(); +} + +/** Instantiates a semantic logger that emits logs to the console. + * @param context a static payload that is cloned when the logger + * logs a message. The `messages`, `level`, and `content` fields + * are reserved for use by loggers. + * @param settings specializes how the semantic logger functions. + * If this is omitted, the logger suppresses debug messages. + */ +export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger { + return new DefaultSemanticLogger(logger, {}); +} diff --git a/libs/common/src/tools/log/index.ts b/libs/common/src/tools/log/index.ts new file mode 100644 index 00000000000..1f86ca4e4d7 --- /dev/null +++ b/libs/common/src/tools/log/index.ts @@ -0,0 +1,2 @@ +export { disabledSemanticLoggerProvider, consoleSemanticLoggerProvider } from "./factory"; +export { SemanticLogger } from "./semantic-logger.abstraction"; diff --git a/libs/common/src/tools/log/semantic-logger.abstraction.ts b/libs/common/src/tools/log/semantic-logger.abstraction.ts new file mode 100644 index 00000000000..196d1f3f12c --- /dev/null +++ b/libs/common/src/tools/log/semantic-logger.abstraction.ts @@ -0,0 +1,94 @@ +import { Jsonify } from "type-fest"; + +/** Semantic/structural logging component */ +export interface SemanticLogger { + /** Logs a message at debug priority. + * Debug messages are used for diagnostics, and are typically disabled + * in production builds. + * @param message - a message to record in the log's `message` field. + */ + debug(message: string): void; + + /** Logs the content at debug priority. + * Debug messages are used for diagnostics, and are typically disabled + * in production builds. + * @param content - JSON content included in the log's `content` field. + * @param message - a message to record in the log's `message` field. + */ + debug(content: Jsonify, message?: string): void; + + /** combined signature for overloaded methods */ + debug(content: Jsonify | string, message?: string): void; + + /** Logs a message at informational priority. + * Information messages are used for status reports. + * @param message - a message to record in the log's `message` field. + */ + info(message: string): void; + + /** Logs the content at informational priority. + * Information messages are used for status reports. + * @param content - JSON content included in the log's `content` field. + * @param message - a message to record in the log's `message` field. + */ + info(content: Jsonify, message?: string): void; + + /** combined signature for overloaded methods */ + info(content: Jsonify | string, message?: string): void; + + /** Logs a message at warn priority. + * Warn messages are used to indicate a operation that may affect system + * stability occurred. + * @param message - a message to record in the log's `message` field. + */ + warn(message: string): void; + + /** Logs the content at warn priority. + * Warn messages are used to indicate a operation that may affect system + * stability occurred. + * @param content - JSON content included in the log's `content` field. + * @param message - a message to record in the log's `message` field. + */ + warn(content: Jsonify, message?: string): void; + + /** combined signature for overloaded methods */ + warn(content: Jsonify | string, message?: string): void; + + /** Logs a message at error priority. + * Error messages are used to indicate a operation that affects system + * stability occurred and the system was able to recover. + * @param message - a message to record in the log's `message` field. + */ + error(message: string): void; + + /** Logs the content at debug priority. + * Error messages are used to indicate a operation that affects system + * stability occurred and the system was able to recover. + * @param content - JSON content included in the log's `content` field. + * @param message - a message to record in the log's `message` field. + */ + error(content: Jsonify, message?: string): void; + + /** combined signature for overloaded methods */ + error(content: Jsonify | string, message?: string): void; + + /** Logs a message at panic priority and throws an error. + * Panic messages are used to indicate a operation that affects system + * stability occurred and the system cannot recover. Panic messages + * log an error and throw an `Error`. + * @param message - a message to record in the log's `message` field. + */ + panic(message: string): never; + + /** Logs the content at debug priority and throws an error. + * Panic messages are used to indicate a operation that affects system + * stability occurred and the system cannot recover. Panic messages + * log an error and throw an `Error`. + * @param content - JSON content included in the log's `content` field. + * @param message - a message to record in the log's `message` field. + */ + panic(content: Jsonify, message?: string): never; + + /** combined signature for overloaded methods */ + panic(content: Jsonify | string, message?: string): never; +}