/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const INTERNAL_FIELDS = new Set(["_level", "_message", "_time", "_namespace"]); /* * Dump a message everywhere we can if we have a failure. */ function dumpError(text) { dump(text + "\n"); // TODO: Bug 1801091 - Figure out how to replace this. // eslint-disable-next-line mozilla/no-cu-reportError Cu.reportError(text); } export var Log = { Level: { Fatal: 70, Error: 60, Warn: 50, Info: 40, Config: 30, Debug: 20, Trace: 10, All: -1, // We don't want All to be falsy. Desc: { 70: "FATAL", 60: "ERROR", 50: "WARN", 40: "INFO", 30: "CONFIG", 20: "DEBUG", 10: "TRACE", "-1": "ALL", }, Numbers: { FATAL: 70, ERROR: 60, WARN: 50, INFO: 40, CONFIG: 30, DEBUG: 20, TRACE: 10, ALL: -1, }, }, get repository() { delete Log.repository; Log.repository = new LoggerRepository(); return Log.repository; }, set repository(value) { delete Log.repository; Log.repository = value; }, _formatError(e) { let result = String(e); if (e.fileName) { let loc = [e.fileName]; if (e.lineNumber) { loc.push(e.lineNumber); } if (e.columnNumber) { loc.push(e.columnNumber); } result += `(${loc.join(":")})`; } return `${result} ${Log.stackTrace(e)}`; }, // This is for back compatibility with services/common/utils.js; we duplicate // some of the logic in ParameterFormatter exceptionStr(e) { if (!e) { return String(e); } if (e instanceof Ci.nsIException) { return `${e} ${Log.stackTrace(e)}`; } else if (isError(e)) { return Log._formatError(e); } // else let message = e.message || e; return `${message} ${Log.stackTrace(e)}`; }, stackTrace(e) { if (!e) { return Components.stack.caller.formattedStack.trim(); } // Wrapped nsIException if (e.location) { let frame = e.location; let output = []; while (frame) { // Works on frames or exceptions, munges file:// URIs to shorten the paths // FIXME: filename munging is sort of hackish. let str = ""; let file = frame.filename || frame.fileName; if (file) { str = file.replace(/^(?:chrome|file):.*?([^\/\.]+(\.\w+)+)$/, "$1"); } if (frame.lineNumber) { str += ":" + frame.lineNumber; } if (frame.name) { str = frame.name + "()@" + str; } if (str) { output.push(str); } frame = frame.caller; } return `Stack trace: ${output.join("\n")}`; } // Standard JS exception if (e.stack) { let stack = e.stack; return ( "JS Stack trace: " + stack.trim().replace(/@[^@]*?([^\/\.]+(\.\w+)+:)/g, "@$1") ); } if (e instanceof Ci.nsIStackFrame) { return e.formattedStack.trim(); } return "No traceback available"; }, }; /* * LogMessage * Encapsulates a single log event's data */ class LogMessage { constructor(loggerName, level, message, params) { this.loggerName = loggerName; this.level = level; /* * Special case to handle "log./level/(object)", for example logging a caught exception * without providing text or params like: catch(e) { logger.warn(e) } * Treating this as an empty text with the object in the 'params' field causes the * object to be formatted properly by BasicFormatter. */ if ( !params && message && typeof message == "object" && typeof message.valueOf() != "string" ) { this.message = null; this.params = message; } else { // If the message text is empty, or a string, or a String object, normal handling this.message = message; this.params = params; } // The _structured field will correspond to whether this message is to // be interpreted as a structured message. this._structured = this.params && this.params.action; this.time = Date.now(); } get levelDesc() { if (this.level in Log.Level.Desc) { return Log.Level.Desc[this.level]; } return "UNKNOWN"; } toString() { let msg = `${this.time} ${this.level} ${this.message}`; if (this.params) { msg += ` ${JSON.stringify(this.params)}`; } return `LogMessage [${msg}]`; } } /* * Logger * Hierarchical version. Logs to all appenders, assigned or inherited */ class Logger { constructor(name, repository) { if (!repository) { repository = Log.repository; } this._name = name; this.children = []; this.ownAppenders = []; this.appenders = []; this._repository = repository; this._levelPrefName = null; this._levelPrefValue = null; this._level = null; this._parent = null; } get name() { return this._name; } get level() { if (this._levelPrefName) { // We've been asked to use a preference to configure the logs. If the // pref has a value we use it, otherwise we continue to use the parent. const lpv = this._levelPrefValue; if (lpv) { const levelValue = Log.Level[lpv]; if (levelValue) { // stash it in _level just in case a future value of the pref is // invalid, in which case we end up continuing to use this value. this._level = levelValue; return levelValue; } } else { // in case the pref has transitioned from a value to no value, we reset // this._level and fall through to using the parent. this._level = null; } } if (this._level != null) { return this._level; } if (this.parent) { return this.parent.level; } dumpError("Log warning: root logger configuration error: no level defined"); return Log.Level.All; } set level(level) { if (this._levelPrefName) { // I guess we could honor this by nuking this._levelPrefValue, but it // almost certainly implies confusion, so we'll warn and ignore. dumpError( `Log warning: The log '${this.name}' is configured to use ` + `the preference '${this._levelPrefName}' - you must adjust ` + `the level by setting this preference, not by using the ` + `level setter` ); return; } this._level = level; } get parent() { return this._parent; } set parent(parent) { if (this._parent == parent) { return; } // Remove ourselves from parent's children if (this._parent) { let index = this._parent.children.indexOf(this); if (index != -1) { this._parent.children.splice(index, 1); } } this._parent = parent; parent.children.push(this); this.updateAppenders(); } manageLevelFromPref(prefName) { if (prefName == this._levelPrefName) { // We've already configured this log with an observer for that pref. return; } if (this._levelPrefName) { dumpError( `The log '${this.name}' is already configured with the ` + `preference '${this._levelPrefName}' - ignoring request to ` + `also use the preference '${prefName}'` ); return; } this._levelPrefName = prefName; XPCOMUtils.defineLazyPreferenceGetter(this, "_levelPrefValue", prefName); } updateAppenders() { if (this._parent) { let notOwnAppenders = this._parent.appenders.filter(function (appender) { return !this.ownAppenders.includes(appender); }, this); this.appenders = notOwnAppenders.concat(this.ownAppenders); } else { this.appenders = this.ownAppenders.slice(); } // Update children's appenders. for (let i = 0; i < this.children.length; i++) { this.children[i].updateAppenders(); } } addAppender(appender) { if (this.ownAppenders.includes(appender)) { return; } this.ownAppenders.push(appender); this.updateAppenders(); } removeAppender(appender) { let index = this.ownAppenders.indexOf(appender); if (index == -1) { return; } this.ownAppenders.splice(index, 1); this.updateAppenders(); } _unpackTemplateLiteral(string, params) { if (!Array.isArray(params)) { // Regular log() call. return [string, params]; } if (!Array.isArray(string)) { // Not using template literal. However params was packed into an array by // the this.[level] call, so we need to unpack it here. return [string, params[0]]; } // We're using template literal format (logger.warn `foo ${bar}`). Turn the // template strings into one string containing "${0}"..."${n}" tokens, and // feed it to the basic formatter. The formatter will treat the numbers as // indices into the params array, and convert the tokens to the params. if (!params.length) { // No params; we need to set params to undefined, so the formatter // doesn't try to output the params array. return [string[0], undefined]; } let concat = string[0]; for (let i = 0; i < params.length; i++) { concat += `\${${i}}${string[i + 1]}`; } return [concat, params]; } log(level, string, params) { if (this.level > level) { return; } // Hold off on creating the message object until we actually have // an appender that's responsible. let message; let appenders = this.appenders; for (let appender of appenders) { if (appender.level > level) { continue; } if (!message) { [string, params] = this._unpackTemplateLiteral(string, params); message = new LogMessage(this._name, level, string, params); } appender.append(message); } } fatal(string, ...params) { this.log(Log.Level.Fatal, string, params); } error(string, ...params) { this.log(Log.Level.Error, string, params); } warn(string, ...params) { this.log(Log.Level.Warn, string, params); } info(string, ...params) { this.log(Log.Level.Info, string, params); } config(string, ...params) { this.log(Log.Level.Config, string, params); } debug(string, ...params) { this.log(Log.Level.Debug, string, params); } trace(string, ...params) { this.log(Log.Level.Trace, string, params); } } /* * LoggerRepository * Implements a hierarchy of Loggers */ class LoggerRepository { constructor() { this._loggers = {}; this._rootLogger = null; } get rootLogger() { if (!this._rootLogger) { this._rootLogger = new Logger("root", this); this._rootLogger.level = Log.Level.All; } return this._rootLogger; } set rootLogger(logger) { throw new Error("Cannot change the root logger"); } _updateParents(name) { let pieces = name.split("."); let cur, parent; // find the closest parent // don't test for the logger name itself, as there's a chance it's already // there in this._loggers for (let i = 0; i < pieces.length - 1; i++) { if (cur) { cur += "." + pieces[i]; } else { cur = pieces[i]; } if (cur in this._loggers) { parent = cur; } } // if we didn't assign a parent above, there is no parent if (!parent) { this._loggers[name].parent = this.rootLogger; } else { this._loggers[name].parent = this._loggers[parent]; } // trigger updates for any possible descendants of this logger for (let logger in this._loggers) { if (logger != name && logger.indexOf(name) == 0) { this._updateParents(logger); } } } /** * Obtain a named Logger. * * The returned Logger instance for a particular name is shared among * all callers. In other words, if two consumers call getLogger("foo"), * they will both have a reference to the same object. * * @return Logger */ getLogger(name) { if (name in this._loggers) { return this._loggers[name]; } this._loggers[name] = new Logger(name, this); this._updateParents(name); return this._loggers[name]; } /** * Obtain a Logger that logs all string messages with a prefix. * * A common pattern is to have separate Logger instances for each instance * of an object. But, you still want to distinguish between each instance. * Since Log.repository.getLogger() returns shared Logger objects, * monkeypatching one Logger modifies them all. * * This function returns a new object with a prototype chain that chains * up to the original Logger instance. The new prototype has log functions * that prefix content to each message. * * @param name * (string) The Logger to retrieve. * @param prefix * (string) The string to prefix each logged message with. */ getLoggerWithMessagePrefix(name, prefix) { let log = this.getLogger(name); let proxy = Object.create(log); proxy.log = (level, string, params) => { if (Array.isArray(string) && Array.isArray(params)) { // Template literal. // We cannot change the original array, so create a new one. string = [prefix + string[0]].concat(string.slice(1)); } else { string = prefix + string; // Regular string. } return log.log(level, string, params); }; return proxy; } } /* * Formatters * These massage a LogMessage into whatever output is desired. */ // Basic formatter that doesn't do anything fancy. class BasicFormatter { constructor(dateFormat) { if (dateFormat) { this.dateFormat = dateFormat; } this.parameterFormatter = new ParameterFormatter(); } /** * Format the text of a message with optional parameters. * If the text contains ${identifier}, replace that with * the value of params[identifier]; if ${}, replace that with * the entire params object. If no params have been substituted * into the text, format the entire object and append that * to the message. */ formatText(message) { let params = message.params; if (typeof params == "undefined") { return message.message || ""; } // Defensive handling of non-object params // We could add a special case for NSRESULT values here... let pIsObject = typeof params == "object" || typeof params == "function"; // if we have params, try and find substitutions. if (this.parameterFormatter) { // have we successfully substituted any parameters into the message? // in the log message let subDone = false; let regex = /\$\{(\S*?)\}/g; let textParts = []; if (message.message) { textParts.push( message.message.replace(regex, (_, sub) => { // ${foo} means use the params['foo'] if (sub) { if (pIsObject && sub in message.params) { subDone = true; return this.parameterFormatter.format(message.params[sub]); } return "${" + sub + "}"; } // ${} means use the entire params object. subDone = true; return this.parameterFormatter.format(message.params); }) ); } if (!subDone) { // There were no substitutions in the text, so format the entire params object let rest = this.parameterFormatter.format(message.params); if (rest !== null && rest != "{}") { textParts.push(rest); } } return textParts.join(": "); } return undefined; } format(message) { return ( message.time + "\t" + message.loggerName + "\t" + message.levelDesc + "\t" + this.formatText(message) ); } } /** * Test an object to see if it is a Mozilla JS Error. */ function isError(aObj) { return ( aObj && typeof aObj == "object" && "name" in aObj && "message" in aObj && "fileName" in aObj && "lineNumber" in aObj && "stack" in aObj ); } /* * Parameter Formatters * These massage an object used as a parameter for a LogMessage into * a string representation of the object. */ class ParameterFormatter { constructor() { this._name = "ParameterFormatter"; } format(ob) { try { if (ob === undefined) { return "undefined"; } if (ob === null) { return "null"; } // Pass through primitive types and objects that unbox to primitive types. if ( (typeof ob != "object" || typeof ob.valueOf() != "object") && typeof ob != "function" ) { return ob; } if (ob instanceof Ci.nsIException) { return `${ob} ${Log.stackTrace(ob)}`; } else if (isError(ob)) { return Log._formatError(ob); } // Just JSONify it. Filter out our internal fields and those the caller has // already handled. return JSON.stringify(ob, (key, val) => { if (INTERNAL_FIELDS.has(key)) { return undefined; } return val; }); } catch (e) { dumpError( `Exception trying to format object for log message: ${Log.exceptionStr( e )}` ); } // Fancy formatting failed. Just toSource() it - but even this may fail! try { return ob.toSource(); } catch (_) {} try { return String(ob); } catch (_) { return "[object]"; } } } /* * Appenders * These can be attached to Loggers to log to different places * Simply subclass and override doAppend to implement a new one */ class Appender { constructor(formatter) { this.level = Log.Level.All; this._name = "Appender"; this._formatter = formatter || new BasicFormatter(); } append(message) { if (message) { this.doAppend(this._formatter.format(message)); } } toString() { return `${this._name} [level=${this.level}, formatter=${this._formatter}]`; } } /* * DumpAppender * Logs to standard out */ class DumpAppender extends Appender { constructor(formatter) { super(formatter); this._name = "DumpAppender"; } doAppend(formatted) { dump(formatted + "\n"); } } /* * ConsoleAppender * Logs to the javascript console */ class ConsoleAppender extends Appender { constructor(formatter) { super(formatter); this._name = "ConsoleAppender"; } // XXX this should be replaced with calls to the Browser Console append(message) { if (message) { let m = this._formatter.format(message); if (message.level > Log.Level.Warn) { // TODO: Bug 1801091 - Figure out how to replace this. // eslint-disable-next-line mozilla/no-cu-reportError Cu.reportError(m); return; } this.doAppend(m); } } doAppend(formatted) { Services.console.logStringMessage(formatted); } } Object.assign(Log, { LogMessage, Logger, LoggerRepository, BasicFormatter, Appender, DumpAppender, ConsoleAppender, ParameterFormatter, });