Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion js/enums/logLevelEnum.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
255 changes: 228 additions & 27 deletions js/logging.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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();
Expand All @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the override colors config is only applied at configModel:dataLoaded, it's possible to have a mixed colourization; default might be true but the config false. Any logs triggered before this event might have a different colors config.

}

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;
Expand All @@ -48,77 +117,209 @@ 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;
}
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;
Expand Down
1 change: 1 addition & 0 deletions schema/config.model.schema
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@
"options": [
"debug",
"info",
"success",
"warn",
"error",
"fatal"
Expand Down
1 change: 1 addition & 0 deletions schema/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@
"enum": [
"debug",
"info",
"success",
"warn",
"error",
"fatal"
Expand Down