-
Notifications
You must be signed in to change notification settings - Fork 17
New: add SUCCESS log level, scope logging & color coding (fixes #818) #832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| * @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); | ||
| } | ||
|
|
||
|
|
@@ -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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As the override |
||
| } | ||
|
|
||
| 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,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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand what the displayName |
||
| 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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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; | ||
|
|
||




There was a problem hiding this comment.
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.