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
10 changes: 9 additions & 1 deletion js/enums/logLevelEnum.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// Used to determine if log call should be printed based on log level
/**
* 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.
* @file
* @module core/js/enums/logLevelEnum
* @enum {number}
*/
const LOG_LEVEL = ENUM([
'DEBUG',
'INFO',
'SUCCESS',
'WARN',
'ERROR',
'FATAL'
Expand Down
282 changes: 254 additions & 28 deletions js/logging.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,46 @@
/**
* @file Core logging service providing levelled console output, scoped plugin
* loggers, and event hooks for error-reporting integrations.
* @module core/js/logging
*/
import Adapt from 'core/js/adapt';
import LOG_LEVEL from 'core/js/enums/logLevelEnum';

/**
* @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
*/

/**
* @classdesc Singleton logging service. Wraps `console` output with log-level
* filtering, coloured scoped output for plugins, and once-only deduplication
* for deprecation and removal warnings.
* @fires module:core/js/logging~log
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.

Remove the self-referential module references.
At some point this will be adapt-contrib-core in an npm folder.
The events are fired from this singleton, so can be singleton relative.

* @fires module:core/js/logging~log:debug
* @fires module:core/js/logging~log:info
* @fires module:core/js/logging~log:success
* @fires module:core/js/logging~log:warn
* @fires module:core/js/logging~log:error
* @fires module:core/js/logging~log:fatal
* @fires module:core/js/logging~log:ready
*/
class Logging extends Backbone.Controller {

initialize() {
this._config = {
_isEnabled: true,
_level: LOG_LEVEL.INFO.asLowerCase, // Default log level
_console: true, // Log to console
_warnFirstOnly: true // Show only first of identical removed and deprecated warnings
_warnFirstOnly: true, // Show only first of identical removed and deprecated warnings
_colors: true // Enable colored console output
};
this._warned = {};
this._scopedLoggers = {};
this.listenToOnce(Adapt, 'configModel:dataLoaded', this.onLoadConfigData);
}

Expand All @@ -27,18 +57,24 @@ class Logging extends Backbone.Controller {
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 +84,267 @@ class Logging extends Backbone.Controller {

}

/**
* Logs a message at DEBUG level.
* @param {...*} args - Values to log
*/
debug(...args) {
this._log(LOG_LEVEL.DEBUG, args);
}

/**
* Logs a message at INFO level.
* @param {...*} args - Values to log
*/
info(...args) {
this._log(LOG_LEVEL.INFO, args);
}

/**
* Logs a message at SUCCESS level.
* @param {...*} args - Values to log
*/
success(...args) {
this._log(LOG_LEVEL.SUCCESS, args);
}

/**
* Logs a message at WARN level.
* @param {...*} args - Values to log
*/
warn(...args) {
this._log(LOG_LEVEL.WARN, args);
}

/**
* Logs a message at ERROR level.
* @param {...*} args - Values to log
*/
error(...args) {
this._log(LOG_LEVEL.ERROR, args);
}

/**
* Logs a message at FATAL level.
* @param {...*} args - Values to log
*/
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 and coloured by level
* when `_colors` is enabled. Repeated calls with the same `source` return
* the same cached instance.
* @param {string} source - Cache key and default display name (e.g. `'xAPI'`, `'spoor'`)
* @param {string} [name] - Optional display label; only applied on first call for a given source
* @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);
* @example
* const logger = logging.scope('MyPlugin', 'Feature-X');
* logger.warn('Retrying…');
*/
scope(source, name) {
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.

I don't understand what the displayName name parameter is for. It seems an unnecessary feature. It is passed through the rest of the code as source. Being that the third arguments of _log and _logToConsole are source, which are derived from displayName here.

if (!source || typeof source !== 'string') {
throw new Error('logging.scope() requires a source name string parameter');
}

const displayName = name || source;

// Return cached scoped logger if it exists
if (this._scopedLoggers[source]) {
if (name && this._scopedLoggers[source]._displayName !== displayName) {
this.warn(`logging.scope('${source}'): already cached with a different display name, ignoring '${name}'`);
}
return this._scopedLoggers[source];
}

// Create new scoped logger
const scopedLogger = {
_displayName: displayName,
debug: (...args) => this._log(LOG_LEVEL.DEBUG, args, displayName),
info: (...args) => this._log(LOG_LEVEL.INFO, args, displayName),
success: (...args) => this._log(LOG_LEVEL.SUCCESS, args, displayName),
warn: (...args) => this._log(LOG_LEVEL.WARN, args, displayName),
error: (...args) => this._log(LOG_LEVEL.ERROR, args, displayName),
fatal: (...args) => this._log(LOG_LEVEL.FATAL, args, displayName)
};

// 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.
* @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.
* @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.
*/
warnOnce(...args) {
if (this._hasWarned(args)) {
return;
}
this._log(LOG_LEVEL.WARN, args);
}

_log(level, data) {

const isEnabled = (this._config._isEnabled);
/**
* 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 module:core/js/logging~log
* @fires module:core/js/logging~log:debug
* @fires module:core/js/logging~log:info
* @fires module:core/js/logging~log:success
* @fires module:core/js/logging~log:warn
* @fires module:core/js/logging~log:error
* @fires module:core/js/logging~log:fatal
* @private
*/
_log(level, data, source = null) {

const isEnabled = this._config._isEnabled;
if (!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);
const isLogLevelAllowed = level >= configLevel;
if (!isLogLevelAllowed) 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) {

const shouldLogToConsole = (this._config._console);
/**
* Writes a log entry to the browser console, applying coloured CSS styling
* for scoped loggers when `_colors` is enabled.
* @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;
if (!shouldLogToConsole) return;

const log = [level.asUpperCase + ':'];
data && log.push(...data);
const useColors = this._config._colors && source;
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.

Colours and serialisation are only applied to scopped logs, is this intended?

const prefix = source ? `[${source}]` : level.asUpperCase + ':';

if (useColors) {
// Use colored output for scoped loggers - format entire message as string
const color = this._getColorForLevel(level);
const message = data.map(item => this._serializeArg(item)).join(' ');
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);
console[consoleMethod](`%c${prefix} ${message}`, `background: WhiteSmoke; color: ${color}`);
} else {
console.log(...log);
// Standard output
const log = [prefix];
if (data && data.length > 0) {
log.push(...data);
}

const consoleMethod = this._getConsoleMethod(level);
if (typeof console[consoleMethod] === 'function') {
console[consoleMethod](...log);
} else {
console.log(...log);
}
}
}

/**
* Converts a single log argument to a string, safely serialising objects
* and truncating oversized JSON to prevent console spam.
* @param {*} item - Value to serialise
* @returns {string} String representation of the value
* @private
*/
_serializeArg(item) {
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.

The console in most browsers handles JSON objects by using a separate console JSON view. The view allows you to expand and collapse the object in the console. The JSON view in the console deals with circular references just fine.

Converting the object to a string and/or truncating it will prevent the console JSON view from appearing. Circular object now don't appear in the console. The JSON as text view is much longer than the JSON view output.

Firefox:
Image

Chrome:
Image

if (typeof item !== 'object' || item === null) return String(item);
try {
const str = JSON.stringify(item, null, 2);
// Cap output length to prevent console spam
return str.length > 500 ? str.substring(0, 500) + '...' : str;
} catch {
return '[Circular or non-serializable object]';
}
}

/**
* Returns a CSS named colour for the given log level.
* @param {*} level - LOG_LEVEL enum value
* @returns {string} CSS colour name
* @private
*/
_getColorForLevel(level) {
const colors = {
debug: 'RoyalBlue',
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.

Browsers can be light/dark mode. The colours look like this in dark mode:

Image

The default colouration seems just fine:

Image

info: 'Indigo',
success: 'DarkGreen',
warn: 'Chocolate',
error: 'Crimson',
fatal: 'DarkRed'
};
return colors[level.asLowerCase] || 'black';
}

/**
* 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) {
const mapping = {
[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'
};
return mapping[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
Loading