diff --git a/.eslintrc.js b/.eslintrc.js index d5309b8a8f..a5b0be5d2e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,6 @@ module.exports = { extends: "@vue-storefront/eslint-config-integrations", + rules: { + "class-methods-use-this": "off", + }, }; diff --git a/.gitignore b/.gitignore index c96ba95102..b267f66f11 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,5 @@ coverage # Local version of changeset's changelog .changeset/changelog.js +# Turbo files .turbo -.cache_ggshield diff --git a/packages/logger/__tests__/unit/consolaStructuredLogger.spec.ts b/packages/logger/__tests__/unit/consolaStructuredLogger.spec.ts new file mode 100644 index 0000000000..b158edfe7c --- /dev/null +++ b/packages/logger/__tests__/unit/consolaStructuredLogger.spec.ts @@ -0,0 +1,119 @@ +import { ConsolaStructuredLogger } from "../../src/ConsolaStructuredLogger"; +import { LogLevel } from "../../src/interfaces/LogLevel"; +import { StructuredLog } from "../../src/interfaces/StructuredLog"; + +describe("ConsolaStructuredLogger", () => { + let logger: ConsolaStructuredLogger; + let structuredLog: StructuredLog; + + beforeEach(() => { + structuredLog = { + createLog: jest.fn(), + }; + logger = new ConsolaStructuredLogger(structuredLog); + }); + + it("should create a ConsolaStructuredLogger instance", () => { + expect(logger).toBeInstanceOf(ConsolaStructuredLogger); + }); + + it("should log structured data at the specified level", () => { + const logData = { message: "test message" }; + const level = LogLevel.Info; + + logger.logStructured = jest.fn(); + + logger.log(level, logData); + + expect(logger.logStructured).toHaveBeenCalledWith(level, logData); + }); + + it("should log at the emergency level", () => { + const logData = "emergency log"; + + logger.logStructured = jest.fn(); + + logger.emergency(logData); + + expect(logger.logStructured).toHaveBeenCalledWith( + LogLevel.Emergency, + logData + ); + }); + + it("should log at the alert level", () => { + const logData = "alert log"; + + logger.logStructured = jest.fn(); + + logger.alert(logData); + + expect(logger.logStructured).toHaveBeenCalledWith(LogLevel.Alert, logData); + }); + + it("should log at the critical level", () => { + const logData = "critical log"; + + logger.logStructured = jest.fn(); + + logger.critical(logData); + + expect(logger.logStructured).toHaveBeenCalledWith( + LogLevel.Critical, + logData + ); + }); + + it("should log at the error level", () => { + const logData = "error log"; + + logger.logStructured = jest.fn(); + + logger.error(logData); + + expect(logger.logStructured).toHaveBeenCalledWith(LogLevel.Error, logData); + }); + + it("should log at the warning level", () => { + const logData = "warning log"; + + logger.logStructured = jest.fn(); + + logger.warning(logData); + + expect(logger.logStructured).toHaveBeenCalledWith( + LogLevel.Warning, + logData + ); + }); + + it("should log at the notice level", () => { + const logData = "notice log"; + + logger.logStructured = jest.fn(); + + logger.notice(logData); + + expect(logger.logStructured).toHaveBeenCalledWith(LogLevel.Notice, logData); + }); + + it("should log at the info level", () => { + const logData = "info log"; + + logger.logStructured = jest.fn(); + + logger.info(logData); + + expect(logger.logStructured).toHaveBeenCalledWith(LogLevel.Info, logData); + }); + + it("should log at the debug level", () => { + const logData = "debug log"; + + logger.logStructured = jest.fn(); + + logger.debug(logData); + + expect(logger.logStructured).toHaveBeenCalledWith(LogLevel.Debug, logData); + }); +}); diff --git a/packages/logger/__tests__/unit/loggerFactory.spec.ts b/packages/logger/__tests__/unit/loggerFactory.spec.ts new file mode 100644 index 0000000000..8a8757a26c --- /dev/null +++ b/packages/logger/__tests__/unit/loggerFactory.spec.ts @@ -0,0 +1,21 @@ +import { LoggerFactory } from "../../src/LoggerFactory"; + +describe("LoggerFactory try to create type '$type'", () => { + // We can add more in the future + it.each([ + { + type: "consola-gcp", + expected: "ConsolaStructuredLogger", + }, + ])("should create a $type logger", ({ type, expected }) => { + const result = LoggerFactory.create(type as any); + expect(result.constructor.name).toBe(expected); + }); + + it(`should throw an error for unknown logger type`, () => { + const unknownLoggerType = "some-unknown-logger"; + expect(() => + LoggerFactory.create(unknownLoggerType as any, {}) + ).toThrowError(`Logger type ${unknownLoggerType} is not supported`); + }); +}); diff --git a/packages/logger/__tests__/unit/structuredLog/gcpStructuredLog.spec.ts b/packages/logger/__tests__/unit/structuredLog/gcpStructuredLog.spec.ts new file mode 100644 index 0000000000..0c5fdb03a1 --- /dev/null +++ b/packages/logger/__tests__/unit/structuredLog/gcpStructuredLog.spec.ts @@ -0,0 +1,80 @@ +import { LogLevel } from "../../../src/interfaces/LogLevel"; +import { GCPStructuredLog } from "../../../src/structuredLog/GCPStructuredLog"; + +describe("GCPStructuredLog", () => { + it.each([ + { + logData: "test message", + options: {}, + severity: LogLevel.Alert, + expected: { + message: "test message", + severity: "ALERT", + timestamp: expect.any(String), + trace: undefined, + }, + }, + { + logData: { message: "test message" }, + options: { includeStackTrace: true }, + severity: undefined, + expected: { + message: '{"message":"test message"}', + severity: "DEFAULT", + timestamp: expect.any(String), + trace: undefined, + }, + }, + { + logData: new Error("test error"), + options: { includeStackTrace: true }, + severity: undefined, + expected: { + message: "test error", + severity: "DEFAULT", + timestamp: expect.any(String), + trace: expect.any(String), + }, + }, + { + logData: "another test message", + options: { includeStackTrace: false }, + severity: LogLevel.Info, + expected: { + message: "another test message", + severity: "INFO", + timestamp: expect.any(String), + trace: undefined, + }, + }, + { + logData: { message: "test message with options" }, + options: { includeStackTrace: false }, + severity: LogLevel.Debug, + expected: { + message: '{"message":"test message with options"}', + severity: "DEBUG", + timestamp: expect.any(String), + trace: undefined, + }, + }, + { + logData: new Error("another test error"), + options: { includeStackTrace: true }, + severity: LogLevel.Error, + expected: { + message: "another test error", + severity: "ERROR", + timestamp: expect.any(String), + trace: expect.any(String), + }, + }, + ])( + "should create a GCP structured log", + ({ logData, expected, options, severity }) => { + const log = new GCPStructuredLog(); + const gcpStructuredLog = log.createLog(logData, options, severity as any); + expect(gcpStructuredLog).toEqual(expected); + } + ); +}); diff --git a/packages/logger/jest.config.ts b/packages/logger/jest.config.ts new file mode 100644 index 0000000000..fb70a7a5bd --- /dev/null +++ b/packages/logger/jest.config.ts @@ -0,0 +1,5 @@ +import { baseConfig } from "@vue-storefront/jest-config"; + +export default { + ...baseConfig, +}; diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 0000000000..0dbb9efa43 --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,28 @@ +{ + "name": "@vue-storefront/logger", + "version": "1.0.0", + "main": "lib/index.cjs.js", + "module": "lib/index.es.js", + "types": "lib/index.d.ts", + "license": "MIT", + "files": [ + "lib" + ], + "scripts": { + "build": "rimraf lib && rollup -c", + "test": "yarn test:unit && yarn test:integration", + "test:unit": "jest -c jest.config.ts --coverage", + "test:integration": "echo 'No integration tests available'", + "lint": "eslint . --ext .ts,.js", + "prepublishOnly": "yarn build", + "changesets:version": "yarn changeset version && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install", + "changesets:publish": "yarn build && yarn changeset publish" + }, + "packageManager": "yarn@1.22.10", + "engines": { + "node": ">=18" + }, + "dependencies": { + "consola": "^3" + } +} diff --git a/packages/logger/rollup.config.js b/packages/logger/rollup.config.js new file mode 100644 index 0000000000..29d3dbe725 --- /dev/null +++ b/packages/logger/rollup.config.js @@ -0,0 +1,4 @@ +import { generateBaseConfig } from "@vue-storefront/rollup-config"; +import package_ from "./package.json"; + +export default [generateBaseConfig(package_)]; diff --git a/packages/logger/src/ConsolaStructuredLogger.ts b/packages/logger/src/ConsolaStructuredLogger.ts new file mode 100644 index 0000000000..4285fb95f8 --- /dev/null +++ b/packages/logger/src/ConsolaStructuredLogger.ts @@ -0,0 +1,130 @@ +import consola, { + ConsolaOptions, + ConsolaReporter, + createConsola, +} from "consola"; +import { LogLevel } from "./interfaces/LogLevel"; +import type { Logger } from "./interfaces/Logger"; +import type { LoggerOptions } from "./interfaces/LoggerOptions"; +import type { StructuredLog } from "./interfaces/StructuredLog"; +import type { StructuredLogger } from "./interfaces/StructuredLogger"; + +interface ConsolaLoggerOptions + extends LoggerOptions, + Partial> {} +export class ConsolaStructuredLogger implements Logger, StructuredLogger { + /** + * The logger instance from the `consola` package. + */ + private logger: typeof consola; + + /** + * The options for the logger. + */ + private options: LoggerOptions; + + /** + * The structured log builder for GCP. + */ + private structuredLog: StructuredLog; + + private levelMap: Record = { + [LogLevel.Emergency]: 0, + [LogLevel.Alert]: 0, + [LogLevel.Critical]: 0, + [LogLevel.Error]: 0, + [LogLevel.Warning]: 1, + [LogLevel.Notice]: 2, + [LogLevel.Info]: 3, + [LogLevel.Debug]: 4, + }; + + private jsonReporter: ConsolaReporter = { + log: (logObject) => { + console.log(JSON.stringify(logObject.args[0].structuredLog)); + }, + }; + + constructor( + structuredLog: StructuredLog, + options: ConsolaLoggerOptions = { + level: LogLevel.Info, + includeStackTrace: true, + } + ) { + this.logger = createConsola({ + level: this.mapToConsolaLevel(options.level), + reporters: [this.jsonReporter], + }); + + if (options.reporters) { + this.logger.setReporters(options.reporters); + } + + this.structuredLog = structuredLog; + this.options = { + level: LogLevel.Info, + includeStackTrace: true, + ...options, + }; + } + + private mapToConsolaLevel(level: LogLevel): number { + return this.levelMap?.[level] ?? LogLevel.Info; // Default to consola 'info' + } + + public logStructured(level: LogLevel, logData: unknown): void { + const structuredLog = this.structuredLog.createLog( + logData, + this.options, + level + ); + + const consolaLevel = this.mapToConsolaLevel(level); + + switch (consolaLevel) { + case 0: + this.logger.error({ structuredLog }); + break; + case 1: + this.logger.warn({ structuredLog }); + break; + case 2: + this.logger.log({ structuredLog }); + break; + case 3: + this.logger.info({ structuredLog }); + break; + case 4: + this.logger.debug({ structuredLog }); + break; + default: + this.logger.info({ structuredLog }); + break; + } + } + + public log(level: LogLevel, logData: unknown): void { + this.logStructured(level, logData); + } + + private logAtLevel(level: LogLevel) { + return (logData: unknown) => this.log(level, logData); + } + + public emergency = this.logAtLevel(LogLevel.Emergency); + + public alert = this.logAtLevel(LogLevel.Alert); + + public critical = this.logAtLevel(LogLevel.Critical); + + public error = this.logAtLevel(LogLevel.Error); + + public warning = this.logAtLevel(LogLevel.Warning); + + public notice = this.logAtLevel(LogLevel.Notice); + + public info = this.logAtLevel(LogLevel.Info); + + public debug = this.logAtLevel(LogLevel.Debug); +} diff --git a/packages/logger/src/LoggerFactory.ts b/packages/logger/src/LoggerFactory.ts new file mode 100644 index 0000000000..4a323c1e84 --- /dev/null +++ b/packages/logger/src/LoggerFactory.ts @@ -0,0 +1,25 @@ +import { ConsolaStructuredLogger } from "./ConsolaStructuredLogger"; +import { GCPStructuredLog } from "./structuredLog/GCPStructuredLog"; +import { LoggerOptions } from "./interfaces/LoggerOptions"; + +export enum LoggerType { + ConsolaGcp = "consola-gcp", +} + +/** + * Creates a logger based on the type + * Available types: + * - consola-gcp (Consola logger with GCP structured log) + * + * @param type The type of logger to create + */ +export class LoggerFactory { + static create(type: LoggerType, options: LoggerOptions = {}) { + switch (type) { + case LoggerType.ConsolaGcp: + return new ConsolaStructuredLogger(new GCPStructuredLog(), options); + default: + throw new Error(`Logger type ${type} is not supported`); + } + } +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 0000000000..d285c4a4e1 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,5 @@ +export * from "./interfaces"; + +export * from "./LoggerFactory"; +export * from "./ConsolaStructuredLogger"; +export * from "./structuredLog"; diff --git a/packages/logger/src/interfaces/LogLevel.ts b/packages/logger/src/interfaces/LogLevel.ts new file mode 100644 index 0000000000..c01a697a34 --- /dev/null +++ b/packages/logger/src/interfaces/LogLevel.ts @@ -0,0 +1,15 @@ +/** + * Based on the syslog levels defined in RFC 5424. + * + * @see https://datatracker.ietf.org/doc/html/rfc5424 + */ +export enum LogLevel { + Emergency = 0, + Alert = 1, + Critical = 2, + Error = 3, + Warning = 4, + Notice = 5, + Info = 6, + Debug = 7, +} diff --git a/packages/logger/src/interfaces/Logger.ts b/packages/logger/src/interfaces/Logger.ts new file mode 100644 index 0000000000..4eebd1f907 --- /dev/null +++ b/packages/logger/src/interfaces/Logger.ts @@ -0,0 +1,16 @@ +import { LogLevel } from "./LogLevel"; + +/** + * Common interface for a logger. + */ +export interface Logger { + log(level: LogLevel, logData: unknown): void; + emergency(logData: unknown): void; + alert(logData: unknown): void; + critical(logData: unknown): void; + error(logData: unknown): void; + warning(logData: unknown): void; + notice(logData: unknown): void; + info(logData: unknown): void; + debug(logData: unknown): void; +} diff --git a/packages/logger/src/interfaces/LoggerOptions.ts b/packages/logger/src/interfaces/LoggerOptions.ts new file mode 100644 index 0000000000..0864a8a6f3 --- /dev/null +++ b/packages/logger/src/interfaces/LoggerOptions.ts @@ -0,0 +1,16 @@ +import { LogLevel } from "./LogLevel"; + +/** + * Options for the logger. + */ +export interface LoggerOptions { + /** + * The log level aligned with RFC5424. + */ + level?: LogLevel; + + /** + * Whether to include the stack trace in the log message. + */ + includeStackTrace?: boolean; +} diff --git a/packages/logger/src/interfaces/StructuredLog.ts b/packages/logger/src/interfaces/StructuredLog.ts new file mode 100644 index 0000000000..93618b5d2d --- /dev/null +++ b/packages/logger/src/interfaces/StructuredLog.ts @@ -0,0 +1,16 @@ +import { LoggerOptions } from "./LoggerOptions"; +import { LogLevel } from "./LogLevel"; + +/** + * Interface for creating structured logs for different logging services. + */ +export interface StructuredLog { + /** + * Create a structured log for the given log data. + */ + createLog( + logData: unknown, + options: Pick, + severity?: LogLevel + ): Record; +} diff --git a/packages/logger/src/interfaces/StructuredLogger.ts b/packages/logger/src/interfaces/StructuredLogger.ts new file mode 100644 index 0000000000..8b86c6f351 --- /dev/null +++ b/packages/logger/src/interfaces/StructuredLogger.ts @@ -0,0 +1,5 @@ +import { LogLevel } from "./LogLevel"; + +export interface StructuredLogger { + logStructured(level: LogLevel, logData: unknown): void; +} diff --git a/packages/logger/src/interfaces/gcp/GCPStructuredLogger.ts b/packages/logger/src/interfaces/gcp/GCPStructuredLogger.ts new file mode 100644 index 0000000000..61ff560e43 --- /dev/null +++ b/packages/logger/src/interfaces/gcp/GCPStructuredLogger.ts @@ -0,0 +1,12 @@ +export interface GCPStructuredDTO { + timestamp: string; + severity: string; + message: string; + trace?: string; + sourceLocation?: { + file: string; + line: number; + function?: string; + }; + [key: string]: any; +} diff --git a/packages/logger/src/interfaces/index.ts b/packages/logger/src/interfaces/index.ts new file mode 100644 index 0000000000..70ac0dc93b --- /dev/null +++ b/packages/logger/src/interfaces/index.ts @@ -0,0 +1,6 @@ +export * from "./Logger"; +export * from "./LoggerOptions"; +export * from "./LogLevel"; +export * from "./StructuredLog"; +export * from "./StructuredLogger"; +export * from "./gcp/GCPStructuredLogger"; diff --git a/packages/logger/src/structuredLog/GCPStructuredLog.ts b/packages/logger/src/structuredLog/GCPStructuredLog.ts new file mode 100644 index 0000000000..152e9468c7 --- /dev/null +++ b/packages/logger/src/structuredLog/GCPStructuredLog.ts @@ -0,0 +1,92 @@ +import { GCPStructuredDTO } from "../interfaces/gcp/GCPStructuredLogger"; +import { StructuredLog } from "../interfaces/StructuredLog"; +import { LogLevel } from "../interfaces/LogLevel"; +import { LoggerOptions } from "../interfaces/LoggerOptions"; + +type GCPSeverity = + | "DEFAULT" + | "DEBUG" + | "INFO" + | "NOTICE" + | "WARNING" + | "ERROR" + | "CRITICAL" + | "ALERT" + | "EMERGENCY"; + +type GCPSeverityMap = Record; + +/** + * A structured log for Google Cloud Platform. + */ +export class GCPStructuredLog implements StructuredLog { + /** + * The mapping of log levels to GCP severity levels. + */ + private severityMap: GCPSeverityMap = { + [LogLevel.Emergency]: "EMERGENCY", + [LogLevel.Alert]: "ALERT", + [LogLevel.Critical]: "CRITICAL", + [LogLevel.Error]: "ERROR", + [LogLevel.Warning]: "WARNING", + [LogLevel.Notice]: "NOTICE", + [LogLevel.Info]: "INFO", + [LogLevel.Debug]: "DEBUG", + }; + + /** + * Creates a structured log object for GCP. + */ + public createLog( + logData: unknown, + options: LoggerOptions, + severity?: LogLevel + ): GCPStructuredDTO { + return { + timestamp: this.getCurrentTimestamp(), + severity: this.mapLogLevelToGCPSeverity(severity), + message: this.formatMessage(logData), + trace: options.includeStackTrace ? this.extractTrace(logData) : undefined, + }; + } + + /** + * @returns The current timestamp in ISO 8601 format. + */ + private getCurrentTimestamp(): string { + return new Date().toISOString(); + } + + /** + * + * @returns The GCP severity level for the given log level. + */ + private mapLogLevelToGCPSeverity(level: LogLevel): string { + return this.severityMap[level] || "DEFAULT"; + } + + /** + * + * @returns The formatted log message. + */ + private formatMessage(logData: unknown): string { + if (logData instanceof Error) { + return logData.message; + } + + try { + return typeof logData === "object" + ? JSON.stringify(logData) + : String(logData); + } catch (error) { + return "Unable to stringify log data"; + } + } + + /** + * @returns The stack trace if the log data is an error. + */ + private extractTrace(logData: unknown): string | undefined { + return logData instanceof Error ? logData.stack : undefined; + } +} diff --git a/packages/logger/src/structuredLog/index.ts b/packages/logger/src/structuredLog/index.ts new file mode 100644 index 0000000000..893b84b64d --- /dev/null +++ b/packages/logger/src/structuredLog/index.ts @@ -0,0 +1 @@ +export * from "./GCPStructuredLog"; diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 0000000000..b966bf605d --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@vue-storefront/integrations-tsconfig/tsconfig", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./lib", + "declarationDir": "./lib", + "declaration": true, + "rootDir": "./src" + }, + "exclude": ["node_modules"], + "include": ["src/**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index 026b1dedd0..2a79e5713b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7492,10 +7492,10 @@ confusing-browser-globals@^1.0.10: consola@^2.15.3: version "2.15.3" - resolved "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" + resolved "https://registrynpm.storefrontcloud.io/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== -consola@^3.2.3: +consola@^3, consola@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/consola/-/consola-3.2.3.tgz#0741857aa88cfa0d6fd53f1cff0375136e98502f" integrity sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==