diff --git a/js/enums/logLevelEnum.js b/js/enums/logLevelEnum.js index d73c8bb1..f250780e 100644 --- a/js/enums/logLevelEnum.js +++ b/js/enums/logLevelEnum.js @@ -1,7 +1,18 @@ -// Used to determine if log call should be printed based on log level +/** + * @file Ordered log-level enum used by the logging service. + * @module core/js/enums/logLevelEnum + */ + +/** + * Ordered log levels used to determine whether a log call should be printed. + * Levels are compared ordinally — a configured level of `WARN` will suppress + * `DEBUG`, `INFO`, and `SUCCESS` output. + * @enum {number} + */ const LOG_LEVEL = ENUM([ 'DEBUG', 'INFO', + 'SUCCESS', 'WARN', 'ERROR', 'FATAL' diff --git a/js/logging.js b/js/logging.js index f2c7f1ae..cf88ebdd 100644 --- a/js/logging.js +++ b/js/logging.js @@ -1,6 +1,54 @@ +/** + * @file Core logging service providing levelled console output, scoped plugin + * loggers, and event hooks for error-reporting integrations. + * @module core/js/logging + * @example + * import logging from 'core/js/logging'; + * logging.info('Course ready'); + * const logger = logging.scope('MyPlugin'); + * logger.warn('Something unexpected happened'); + */ import Adapt from 'core/js/adapt'; import LOG_LEVEL from 'core/js/enums/logLevelEnum'; +const CONSOLE_METHOD = { + [LOG_LEVEL.DEBUG.asLowerCase]: 'debug', + [LOG_LEVEL.INFO.asLowerCase]: 'info', + [LOG_LEVEL.SUCCESS.asLowerCase]: 'log', + [LOG_LEVEL.WARN.asLowerCase]: 'warn', + [LOG_LEVEL.ERROR.asLowerCase]: 'error', + [LOG_LEVEL.FATAL.asLowerCase]: 'error' +}; + +/** + * @typedef {Object} ScopedLogger + * @property {Function} debug - Log at DEBUG level with plugin prefix + * @property {Function} info - Log at INFO level with plugin prefix + * @property {Function} success - Log at SUCCESS level with plugin prefix + * @property {Function} warn - Log at WARN level with plugin prefix + * @property {Function} error - Log at ERROR level with plugin prefix + * @property {Function} fatal - Log at FATAL level with plugin prefix + */ + +/** + * @class Logging + * @classdesc Singleton logging service. Wraps `console` output with log-level + * filtering, levelled scoped output for plugins, and once-only deduplication + * for deprecation and removal warnings. + * + * **Note:** Course config (`_logging`) is applied at `configModel:dataLoaded`. + * Any log calls made before that event use the constructor defaults, so early + * output may not reflect the configured level or console settings. + * @fires log + * @fires log:debug + * @fires log:info + * @fires log:success + * @fires log:warn + * @fires log:error + * @fires log:fatal + * @fires log:ready + * @extends {Backbone.Controller} + */ class Logging extends Backbone.Controller { initialize() { @@ -11,9 +59,16 @@ class Logging extends Backbone.Controller { _warnFirstOnly: true // Show only first of identical removed and deprecated warnings }; this._warned = {}; + this._scopedLoggers = {}; this.listenToOnce(Adapt, 'configModel:dataLoaded', this.onLoadConfigData); } + /** + * Handles the `configModel:dataLoaded` event. Loads logging config from the + * course config model and fires `log:ready` to signal that the service is + * fully configured. + * @fires log:ready + */ onLoadConfigData() { this.loadConfig(); @@ -24,21 +79,35 @@ class Logging extends Backbone.Controller { } + /** + * Reads `_logging` config from the course config model and merges it with + * the default config. Also checks for a `loglevel` query string override. + * + * **Note:** This runs at `configModel:dataLoaded`. Logs emitted before this + * point use constructor defaults, so `_level` and `_console` may differ from + * the course-configured values for early output. + */ loadConfig() { if (Adapt.config.has('_logging')) { - this._config = Adapt.config.get('_logging'); + const courseConfig = Adapt.config.get('_logging'); + // Merge course config with defaults instead of replacing + this._config = Object.assign({}, this._config, courseConfig); } - this.checkQueryStringOverride(); + this._checkQueryStringOverride(); } - checkQueryStringOverride() { + /** + * Checks the page query string for a `loglevel` override and applies it + * to the active config if a valid level is found. + * @private + */ + _checkQueryStringOverride() { - // Override default log level with level present in query string - const matches = window.location.search.match(/[?&]loglevel=([a-z]*)/i); - if (!matches || matches.length < 2) return; + const matches = window.location.search.match(/[?&]loglevel=([a-z0-9]+)/i); + if (!matches || !matches[1]) return; const override = LOG_LEVEL(matches[1].toUpperCase()); if (!override) return; @@ -48,36 +117,131 @@ class Logging extends Backbone.Controller { } + /** + * Logs a message at DEBUG level. + * @param {...*} args - Values to log + * @fires log + * @fires log:debug + */ debug(...args) { this._log(LOG_LEVEL.DEBUG, args); } + /** + * Logs a message at INFO level. + * @param {...*} args - Values to log + * @fires log + * @fires log:info + */ info(...args) { this._log(LOG_LEVEL.INFO, args); } + /** + * Logs a message at SUCCESS level. + * @param {...*} args - Values to log + * @fires log + * @fires log:success + */ + success(...args) { + this._log(LOG_LEVEL.SUCCESS, args); + } + + /** + * Logs a message at WARN level. + * @param {...*} args - Values to log + * @fires log + * @fires log:warn + */ warn(...args) { this._log(LOG_LEVEL.WARN, args); } + /** + * Logs a message at ERROR level. + * @param {...*} args - Values to log + * @fires log + * @fires log:error + */ error(...args) { this._log(LOG_LEVEL.ERROR, args); } + /** + * Logs a message at FATAL level. + * @param {...*} args - Values to log + * @fires log + * @fires log:fatal + */ fatal(...args) { this._log(LOG_LEVEL.FATAL, args); } + /** + * Creates a cached, namespaced logger for a plugin or module. + * Every message is prefixed `[source]` in the console. Repeated calls with the same `source` return + * the same cached instance. + * @param {string} source - Cache key and console display name (e.g. `'xAPI'`, `'spoor'`) + * @returns {ScopedLogger} Scoped logger instance + * @throws {Error} If source is not a non-empty string + * @example + * const logger = logging.scope('MyPlugin'); + * logger.success('Data loaded'); + * logger.error('Connection failed', err); + */ + scope(source) { + if (!source || typeof source !== 'string') { + throw new Error('logging.scope() requires a source name string parameter'); + } + + // Return cached scoped logger if it exists + if (this._scopedLoggers[source]) { + return this._scopedLoggers[source]; + } + + // Create new scoped logger + const scopedLogger = { + debug: (...args) => this._log(LOG_LEVEL.DEBUG, args, source), + info: (...args) => this._log(LOG_LEVEL.INFO, args, source), + success: (...args) => this._log(LOG_LEVEL.SUCCESS, args, source), + warn: (...args) => this._log(LOG_LEVEL.WARN, args, source), + error: (...args) => this._log(LOG_LEVEL.ERROR, args, source), + fatal: (...args) => this._log(LOG_LEVEL.FATAL, args, source) + }; + + // Cache the scoped logger + this._scopedLoggers[source] = scopedLogger; + + return scopedLogger; + } + + /** + * Logs a one-time WARN message prefixed with `REMOVED`. + * Use when an API or feature has been removed entirely. + * @param {...*} args - Values to log + * @example + * logging.removed('myPlugin.oldMethod(), use myPlugin.newMethod() instead'); + */ removed(...args) { - args = ['REMOVED'].concat(args); - this.warnOnce(...args); + this.warnOnce('REMOVED', ...args); } + /** + * Logs a one-time WARN message prefixed with `DEPRECATED`. + * Use when an API or feature still works but should no longer be used. + * @param {...*} args - Values to log + * @example + * logging.deprecated('myPlugin.oldProp, use myPlugin.newProp instead'); + */ deprecated(...args) { - args = ['DEPRECATED'].concat(args); - this.warnOnce(...args); + this.warnOnce('DEPRECATED', ...args); } + /** + * Logs a WARN message only the first time it is called with a given set of arguments. + * Subsequent calls with identical arguments are silently discarded when `_warnFirstOnly` is enabled. + * @param {...*} args - Values to log + */ warnOnce(...args) { if (this._hasWarned(args)) { return; @@ -85,40 +249,77 @@ class Logging extends Backbone.Controller { this._log(LOG_LEVEL.WARN, args); } - _log(level, data) { + /** + * Core log dispatch. Checks enabled state and level filter, then delegates + * to console output and fires public log events. + * @param {*} level - LOG_LEVEL enum value + * @param {Array} data - Arguments to log + * @param {string|null} [source] - Optional source/plugin name + * @fires log + * @fires log:debug + * @fires log:info + * @fires log:success + * @fires log:warn + * @fires log:error + * @fires log:fatal + * @private + */ + _log(level, data, source = null) { - const isEnabled = (this._config._isEnabled); - if (!isEnabled) return; + if (!this._config._isEnabled) return; - const configLevel = LOG_LEVEL(this._config._level.toUpperCase()); + const configLevel = LOG_LEVEL((this._config._level ?? LOG_LEVEL.INFO.asLowerCase).toUpperCase()); - const isLogLevelAllowed = (level >= configLevel); - if (!isLogLevelAllowed) return; + if (level < configLevel) return; - this._logToConsole(level, data); + this._logToConsole(level, data, source); // Allow error reporting plugins to hook and report to logging systems - this.trigger('log', level, data); - this.trigger('log:' + level.asLowerCase, level, data); + this.trigger('log', level, data, source); + this.trigger('log:' + level.asLowerCase, level, data, source); } - _logToConsole(level, data) { + /** + * Writes a log entry to the browser console. + * @param {*} level - LOG_LEVEL enum value + * @param {Array} data - Arguments to log + * @param {string|null} [source] - Optional source/plugin name + * @private + */ + _logToConsole(level, data, source = null) { - const shouldLogToConsole = (this._config._console); + const shouldLogToConsole = this._config._console; if (!shouldLogToConsole) return; - const log = [level.asUpperCase + ':']; - data && log.push(...data); + const prefix = source ? `[${source}]` : level.asUpperCase + ':'; + const consoleMethod = this._getConsoleMethod(level); - // is there a matching console method we can use e.g. console.error()? - if (console[level.asLowerCase]) { - console[level.asLowerCase](...log); + if (typeof console[consoleMethod] === 'function') { + console[consoleMethod](prefix, ...data); } else { - console.log(...log); + console.log(prefix, ...data); } + + } + + /** + * Returns the `console` method name appropriate for the given log level. + * @param {*} level - LOG_LEVEL enum value + * @returns {string} Console method name (e.g. `'warn'`, `'error'`) + * @private + */ + _getConsoleMethod(level) { + return CONSOLE_METHOD[level.asLowerCase] ?? 'log'; } + /** + * Checks whether an identical set of arguments has already been logged + * via `warnOnce`. Records the hash on first call. + * @param {Array} args - Arguments to check + * @returns {boolean} `true` if these arguments have already been warned + * @private + */ _hasWarned(args) { if (!this._config._warnFirstOnly) { return false; diff --git a/schema/config.model.schema b/schema/config.model.schema index dc98b78e..977713ca 100644 --- a/schema/config.model.schema +++ b/schema/config.model.schema @@ -425,6 +425,7 @@ "options": [ "debug", "info", + "success", "warn", "error", "fatal" diff --git a/schema/config.schema.json b/schema/config.schema.json index 14139f2e..45c2155b 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -377,6 +377,7 @@ "enum": [ "debug", "info", + "success", "warn", "error", "fatal"