diff --git a/README.md b/README.md index 15324ad..c22de6d 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,30 @@ Right now the package uses `window.console` as log appender and produces the fol [WARN] mail: it's just a warning, carry on { app: 'mail', uid: 'christoph' } ``` +### Log level The logger tries to detect the server configured logging level by default, which can be configured using the `loglevel_frontend` option in the `config.php`. -In case no logging level was configured or detection failed, the logger will fallback to the *warning* level. -If the server is set to the debug mode the configured logging level will be set to the *debug* level. +In case no logging level was configured or detection failed, the logger will fallback to the *warning* level. +If the server is set to the debug mode the fallback will be the *debug* instead of the *warning* level. Any message with a lower level than the configured will not be printed on the console. -You can override the logging level in both cases by setting it manually using the `setLogLevel` function -when building the logger. +#### Override the log level +You can override the logging level in both cases by setting it manually +using the `setLogLevel` function when building the logger. + +It is also possible to debug an app without the need of manually recompile it to change the `setLogLevel`. +To do so the runtime debugging configuration can be changed by running this in the browser console: + +```js +// debug a single app +window.__NC_LOGGER_DEBUG__=['YOUR_APP_ID'] +// debug multiple apps +window.__NC_LOGGER_DEBUG__=['files', 'viewer'] +``` + +This will enforce the *debug* logging level for the specified apps. ## Contributing diff --git a/REUSE.toml b/REUSE.toml index 8e28c03..c15aed3 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Nextcloud " SPDX-PackageDownloadLocation = "https://github.com/nextcloud-libraries/nextcloud-logger" [[annotations]] -path = ["package-lock.json", "package.json", "tsconfig.json"] +path = ["package-lock.json", "package.json", "tsconfig.json", "tests/tsconfig.json"] precedence = "aggregate" SPDX-FileCopyrightText = "2019-2024 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/lib/ALogger.ts b/lib/ALogger.ts new file mode 100644 index 0000000..f4c465c --- /dev/null +++ b/lib/ALogger.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { IContext, ILogger } from './contracts.ts' + +import { LogLevel } from './contracts.ts' + +/** + * Abstract base logger implementing common functionality + */ +export abstract class ALogger implements ILogger { + /** + * The initial logging context set by the constructor (LoggerBuilder factory) + */ + protected abstract context: IContext + + /** + * Log a message with the specified level and context + * + * @param level - The log level requested + * @param message - The log message + * @param context - The logging context + */ + protected abstract log(level: LogLevel, message: string, context: IContext): void + + public debug(message: string | Error, context?: IContext): void { + this.logIfNeeded(LogLevel.Debug, message, { ...context, ...this.context }) + } + + public info(message: string | Error, context?: IContext): void { + this.logIfNeeded(LogLevel.Info, message, { ...context, ...this.context }) + } + + public warn(message: string | Error, context?: IContext): void { + this.logIfNeeded(LogLevel.Warn, message, { ...context, ...this.context }) + } + + public error(message: string | Error, context?: IContext): void { + this.logIfNeeded(LogLevel.Error, message, { ...context, ...this.context }) + } + + public fatal(message: string | Error, context?: IContext): void { + this.logIfNeeded(LogLevel.Fatal, message, { ...context, ...this.context }) + } + + /** + * Check if the message should be logged and prepare the context + * + * @param level - The logging level requested + * @param message - The log message or Error object + * @param context - The logging context + */ + private logIfNeeded(level: LogLevel, message: string | Error, context: IContext): void { + // Skip if level is configured and this is below the level + if (typeof this.context?.level === 'number' && level < this.context?.level && !this.debuggingEnabled()) { + return + } + + // Handle logging when only an error was passed as log message + if (typeof message === 'object') { + if (context.error) { + context.error = [context.error, message] + } else { + context.error = message + } + if (level === LogLevel.Debug || this.debuggingEnabled()) { + context.stacktrace = message.stack + } + return this.log(level, `${message.name}: ${message.message}`, context) + } + + this.log(level, message, context) + } + + /** + * Check if debugging is enabled for the current app + */ + private debuggingEnabled(): boolean { + const debugContexts = window.__NC_LOGGER_DEBUG__ + return typeof this.context.app === 'string' + && Array.isArray(debugContexts) + && debugContexts.includes(this.context.app) + } +} diff --git a/lib/ConsoleLogger.ts b/lib/ConsoleLogger.ts index 1c9d7e4..7fdbb13 100644 --- a/lib/ConsoleLogger.ts +++ b/lib/ConsoleLogger.ts @@ -5,51 +5,19 @@ import type { IContext, ILogger } from './contracts.ts' +import { ALogger } from './ALogger.ts' import { LogLevel } from './contracts.ts' /* eslint-disable no-console -- This class is a console logger so it needs to write to the console. */ -export class ConsoleLogger implements ILogger { - private context: IContext +export class ConsoleLogger extends ALogger implements ILogger { + protected context: IContext constructor(context?: IContext) { + super() this.context = context || {} } - private formatMessage(message: string | Error, level: LogLevel, context?: IContext): string { - let msg = '[' + LogLevel[level].toUpperCase() + '] ' - - if (context && context.app) { - msg += context.app + ': ' - } - - if (typeof message === 'string') { - return msg + message - } - - // basic error formatting - msg += `Unexpected ${message.name}` - if (message.message) { - msg += ` "${message.message}"` - } - // only add stack trace when debugging - if (level === LogLevel.Debug && message.stack) { - msg += `\n\nStack trace:\n${message.stack}` - } - - return msg - } - - log(level: LogLevel, message: string | Error, context: IContext) { - // Skip if level is configured and this is below the level - if (typeof this.context?.level === 'number' && level < this.context?.level) { - return - } - - // Add error object to context - if (typeof message === 'object' && context?.error === undefined) { - context.error = message - } - + protected log(level: LogLevel, message: string | Error, context: IContext) { switch (level) { case LogLevel.Debug: console.debug(this.formatMessage(message, LogLevel.Debug, context), context) @@ -70,24 +38,28 @@ export class ConsoleLogger implements ILogger { } } - debug(message: string | Error, context?: IContext): void { - this.log(LogLevel.Debug, message, { ...this.context, ...context }) - } + private formatMessage(message: string | Error, level: LogLevel, context?: IContext): string { + let msg = '[' + LogLevel[level].toUpperCase() + '] ' - info(message: string | Error, context?: IContext): void { - this.log(LogLevel.Info, message, { ...this.context, ...context }) - } + if (context && context.app) { + msg += context.app + ': ' + } - warn(message: string | Error, context?: IContext): void { - this.log(LogLevel.Warn, message, { ...this.context, ...context }) - } + if (typeof message === 'string') { + return msg + message + } - error(message: string | Error, context?: IContext): void { - this.log(LogLevel.Error, message, { ...this.context, ...context }) - } + // basic error formatting + msg += `Unexpected ${message.name}` + if (message.message) { + msg += ` "${message.message}"` + } + // only add stack trace when debugging + if (level === LogLevel.Debug && message.stack) { + msg += `\n\nStack trace:\n${message.stack}` + } - fatal(message: string | Error, context?: IContext): void { - this.log(LogLevel.Fatal, message, { ...this.context, ...context }) + return msg } } diff --git a/lib/contracts.ts b/lib/contracts.ts index 85c5330..208e0e9 100644 --- a/lib/contracts.ts +++ b/lib/contracts.ts @@ -2,6 +2,7 @@ * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ + export enum LogLevel { Debug = 0, Info = 1, diff --git a/lib/global.d.ts b/lib/global.d.ts index 8efcdbf..b0ebbc6 100644 --- a/lib/global.d.ts +++ b/lib/global.d.ts @@ -7,6 +7,8 @@ import type { LogLevel } from './contracts.ts' declare global { interface Window { + __NC_LOGGER_DEBUG__?: string[] + _oc_config: { loglevel: LogLevel } diff --git a/tests/ConsoleLogger.spec.ts b/tests/ConsoleLogger.spec.ts index 2fdd4c1..84f90ec 100644 --- a/tests/ConsoleLogger.spec.ts +++ b/tests/ConsoleLogger.spec.ts @@ -2,20 +2,20 @@ * SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -import { afterEach, describe, expect, it, test, vi } from 'vitest' -import { ConsoleLogger, buildConsoleLogger } from '../lib/ConsoleLogger' + +import { beforeEach, describe, expect, it, test, vi } from 'vitest' +import { buildConsoleLogger, ConsoleLogger } from '../lib/ConsoleLogger.ts' // Dummy Error class MyError extends Error { - constructor(msg: string) { super(msg) this.name = 'MyError' } - } -afterEach(() => { +beforeEach(() => { + delete window.__NC_LOGGER_DEBUG__ vi.resetAllMocks() }) @@ -33,8 +33,6 @@ test('building the console logger', () => { }) describe('ConsoleLogger', () => { - afterEach(() => { vi.resetAllMocks() }) - it('logs debug messages', () => { const logger = new ConsoleLogger() const debug = vi.spyOn(window.console, 'debug').mockImplementation(() => {}) @@ -94,10 +92,10 @@ describe('ConsoleLogger', () => { const logger = new ConsoleLogger({ one: 1, two: 2 }) const debug = vi.spyOn(window.console, 'debug').mockImplementation(() => {}) - logger.debug('Should be logged', { two: 3 }) + logger.debug('Should be logged', { three: 3 }) expect(debug).toHaveBeenCalledTimes(1) expect(debug.mock.calls[0][0]).toBe('[DEBUG] Should be logged') - expect(debug.mock.calls[0][1]).toEqual({ one: 1, two: 3 }) + expect(debug.mock.calls[0][1]).toEqual({ one: 1, two: 2, three: 3 }) }) it('allows extending empty global context', () => { @@ -119,6 +117,20 @@ describe('ConsoleLogger', () => { expect(debug).toHaveBeenCalledTimes(0) }) + it('respects the runtime debug configuration', () => { + const logger = new ConsoleLogger({ app: 'test', level: 2 }) + + const debug = vi.spyOn(window.console, 'debug') + debug.mockImplementationOnce(() => {}) + + logger.debug('Should not be logged') + expect(debug).toHaveBeenCalledTimes(0) + + window.__NC_LOGGER_DEBUG__ = ['files', 'test'] + logger.debug('Should be logged now') + expect(debug).toHaveBeenCalledTimes(1) + }) + it('logs Error objects', () => { const error = new MyError('some message') const logger = new ConsoleLogger({}) @@ -128,10 +140,9 @@ describe('ConsoleLogger', () => { logger.warn(error) expect(warn).toHaveBeenCalledTimes(1) - expect(console[0][0]).toContain('MyError') - expect(console[0][0]).toContain('some message') - expect(console[0][0]).not.toContain('Stack trace') + expect(console[0][0]).toMatch('MyError: some message') expect(console[0][1]).toHaveProperty('error', error) + expect(console[0][1]).not.toHaveProperty('stacktrace', error.stack) }) it('logs Error objects and stack trace on debug', () => { @@ -143,9 +154,9 @@ describe('ConsoleLogger', () => { logger.debug(error) expect(debug).toHaveBeenCalledTimes(1) - expect(console[0][0]).toContain('MyError') - expect(console[0][0]).toContain('some message') - expect(console[0][0]).toContain('Stack trace:') + expect(console[0][0]).toContain('MyError: some message') + expect(console[0][1]).toHaveProperty('error', error) + expect(console[0][1]).toHaveProperty('stacktrace', error.stack) }) it('logs Error objects and does not override context', () => { @@ -159,6 +170,8 @@ describe('ConsoleLogger', () => { expect(warn).toHaveBeenCalledTimes(1) expect(console[0][0]).toContain('MyError') expect(console[0][0]).toContain('some message') - expect(console[0][1]).toHaveProperty('error', 'none') + expect(console[0][1]).toHaveProperty('error') + // @ts-expect-error - We know error is an array here + expect(console[0][1]!.error).toEqual(['none', error]) }) }) diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..6001899 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["../lib", "."] +} \ No newline at end of file