Source RapidContext_Log.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15/**
16 * Provides a logging service for debugging apps and server calls.
17 *
18 * All log messages are filtered by log level and either discarded or
19 * stored to an internal array. Log messages on the error or warning levels
20 * are also sent to the server for remote logging.
21 *
22 * This module replaces the built-in `console.error()`, `console.warn()`,
23 * `console.info()`, `console.log()` and `console.debug()` functions with its
24 * own versions, passing through the log messages if not filtered.
25 *
26 * @namespace RapidContext.Log
27 */
28(function (window) {
29
30    // The original console logger functions
31    var backup = {};
32
33    // The current log state
34    var state = {
35        count: 0,
36        level: 3,
37        context: null,
38        history: [],
39        publish: {
40            last: 0,
41            timer: null
42        }
43    };
44
45    // The configuration settings
46    var config = {
47        interval: 10000,
48        url: "rapidcontext/log",
49        filter: null,
50        publisher: null
51    };
52
53    /**
54     * Initializes and configures the logging module. Will modify the `console`
55     * object for logging. This replaces the default `console.error`,
56     * `console.warn`, `console.info`, `console.log` and `console.debug`
57     * functions. Safe to call multiple times to change or update config.
58     *
59     * @param {Object} [opts] the log configuration options, or null
60     * @param {number} [opts.interval] the publish delay in millis, default is 10s
61     * @param {string} [opts.url] the publish URL endpoint
62     * @param {function} [opts.filter] the event filter, returns a boolean
63     * @param {function} [opts.publisher] the event publisher, returns a Promise
64     *
65     * @memberof RapidContext.Log
66     */
67    function init(opts) {
68        function overwrite(obj, key, fn) {
69            if (obj[key] !== fn) {
70                backup[key] = obj[key] || function () {};
71                obj[key] = fn;
72            }
73        }
74        if (typeof(window.console) !== "object") {
75            window.console = {};
76        }
77        overwrite(window.console, "error", error);
78        overwrite(window.console, "warn", warn);
79        overwrite(window.console, "info", info);
80        overwrite(window.console, "log", debug);
81        overwrite(window.console, "debug", debug);
82        overwrite(window, "onerror", _onerror);
83        opts = opts || {};
84        config.interval = opts.interval || config.interval;
85        config.url = opts.url || config.url;
86        config.filter = opts.filter || _isErrorOrWarning;
87        config.publisher = opts.publisher || _publish;
88    }
89
90    /**
91     * Clears the log console and the array of stored messages.
92     *
93     * @see RapidContext.Log.history
94     *
95     * @memberof RapidContext.Log
96     */
97    function clear() {
98        state.history = [];
99        if (window.console && typeof(window.console.clear) === "function") {
100            window.console.clear();
101        }
102    }
103
104    /**
105     * Returns the history of filtered log entries. Each log entry is a plain
106     * object with properties -- `id`, `time`, `level`, `context`, `message`
107     * and `data`.
108     *
109     * @return {Array} the array of log entries
110     *
111     * @memberof RapidContext.Log
112     */
113    function history() {
114        return state.history.slice(0);
115    }
116
117    /**
118     * Returns and optionally sets the current log level. The supported log
119     * level values are -- "none", "error", "warn", "info", "log" and "all".
120     *
121     * @param {string} [value] the new log level
122     * @return {string} the current log level
123     *
124     * @memberof RapidContext.Log
125     */
126    function level(value) {
127        if (typeof(value) !== "undefined") {
128            if (value === 0 || /^none/i.test(value)) {
129                state.level = 0;
130            } else if (value === 1 || /^err/i.test(value)) {
131                state.level = 1;
132            } else if (value === 2 || /^warn/i.test(value)) {
133                state.level = 2;
134            } else if (value === 3 || /^info/i.test(value)) {
135                state.level = 3;
136            } else if (value === 4 || /^(log|debug|trace)/i.test(value)) {
137                state.level = 4;
138            } else {
139                state.level = 5;
140            }
141        }
142        if (state.level <= 0) {
143            return "none";
144        } else if (state.level <= 1) {
145            return "error";
146        } else if (state.level <= 2) {
147            return "warn";
148        } else if (state.level <= 3) {
149            return "info";
150        } else if (state.level <= 4) {
151            return "log";
152        } else {
153            return "all";
154        }
155    }
156
157    /**
158     * Returns and optionally sets the current log context. The log context is
159     * used to tag all subsequent log messages until the context is removed or
160     * modified.
161     *
162     * @param {string} [value] the new log context, or null to clear
163     *
164     * @return {string} the current log context, or null for none
165     *
166     * @example
167     * RapidContext.Log.context('mybutton.onclick');
168     * ...
169     * console.warn('unsupported xyz value:', value);
170     * ...
171     * RapidContext.Log.context(null);
172     *
173     * @memberof RapidContext.Log
174     */
175    function context(value) {
176        if (typeof(value) !== "undefined") {
177            state.context = value;
178            if (!value) {
179                // Clear group immediately, but create new group on first log
180                _group();
181            }
182        }
183        return state.context;
184    }
185
186    /**
187     * Logs an error message with optional data. Also available as the global
188     * `console.error()` function.
189     *
190     * @param {string} msg the log message
191     * @param {...Object} [data] the additional log data or messages
192     *
193     * @example
194     * console.error('failed to initialize module');
195     *
196     * @memberof RapidContext.Log
197     */
198    function error(msg/**, ...*/) {
199        if (state.level >= 1) {
200            var args = Array.prototype.slice.call(arguments);
201            _log("error", args);
202            _store("error", args.map(stringify));
203        }
204    }
205
206    /**
207     * Logs a warning message with optional data. Also available as the global
208     * `console.warn()` function.
209     *
210     * @param {string} msg the log message
211     * @param {...Object} [data] the additional log data or messages
212     *
213     * @example
214     * console.warn('missing "data" attribute on document root:', document.body);
215     *
216     * @memberof RapidContext.Log
217     */
218    function warn(msg/**, ...*/) {
219        if (state.level >= 2) {
220            var args = Array.prototype.slice.call(arguments);
221            _log("warn", args);
222            _store("warn", args.map(stringify));
223        }
224    }
225
226    /**
227     * Logs an information message with optional data. Also available as the
228     * global `console.info()` function.
229     *
230     * @param {string} msg the log message
231     * @param {...Object} [data] the additional log data or messages
232     *
233     * @example
234     * console.info('authorization failed, user not logged in');
235     *
236     * @memberof RapidContext.Log
237     */
238    function info(msg/**, ...*/) {
239        if (state.level >= 3) {
240            var args = Array.prototype.slice.call(arguments);
241            _log("info", args);
242            _store("info", args.map(stringify));
243        }
244    }
245
246    /**
247     * Logs a debug message with optional data. Also available as the global
248     * `console.log()` and `console.debug()` functions.
249     *
250     * @param {string} msg the log message
251     * @param {...Object} [data] the additional log data or messages
252     *
253     * @example
254     * console.log('init AJAX call to URL:', url);
255     * ...
256     * console.log('done AJAX call to URL:', url, responseCode);
257     *
258     * @memberof RapidContext.Log
259     */
260    function debug(msg/**, ...*/) {
261        if (state.level >= 4) {
262            var args = Array.prototype.slice.call(arguments);
263            _log("log", args);
264            _store("log", args.map(stringify));
265        }
266    }
267
268    /**
269     * Handles window.onerror events (global uncaught errors).
270     */
271    function _onerror(msg, url, line, col, err) {
272        if (err instanceof Error && err.stack) {
273            error(msg || "Uncaught error", err);
274        } else {
275            var location = [url, line, col].filter(Boolean).join(":");
276            error(msg || "Uncaught error", location, err);
277        }
278        return true;
279    }
280
281    /**
282     * Logs a message to one of the console loggers.
283     *
284     * @param {string} level the log level (i.e. function 'error', 'warn'...)
285     * @param {Array} args the log message & data (as raw objects)
286     */
287    function _log(level, args) {
288        var logger = backup[level];
289        if (typeof(logger) === "function") {
290            _group(state.context);
291            logger.apply(window.console, args);
292        }
293    }
294
295    /**
296     * Calls the `console.group` and `console.groupEnd` functions (if
297     * they exist) to change the group label (if needed).
298     *
299     * @param {string} label the log context label
300     */
301    function _group(label) {
302        var console = window.console;
303        if (console._group !== label) {
304            if (console._group) {
305                delete console._group;
306                if (typeof(console.groupEnd) === "function") {
307                    console.groupEnd();
308                }
309            }
310            if (label) {
311                console._group = label;
312                if (typeof(console.group) === "function") {
313                    console.group(label + ":");
314                }
315            }
316        }
317    }
318
319    /**
320     * Stores a log message to the history.
321     *
322     * @param {string} level the message log level
323     * @param {Array} args the log message arguments (as strings)
324     */
325    function _store(level, args) {
326        var m = 1;
327        for (; m < args.length; m++) {
328            if (args[m] && args[m].indexOf("\n") >= 0) {
329                break;
330            }
331        }
332        state.history.push({
333            id: ++state.count,
334            time: new Date(),
335            level: level,
336            context: state.context,
337            message: args.slice(0, m).join(" "),
338            data: (m < args.length) ? args.slice(m).join("\n") : null
339        });
340        state.history.splice(0, Math.max(0, state.history.length - 100));
341        if (!state.publish.timer) {
342            state.publish.timer = setTimeout(_publishLoop, 100);
343        }
344    }
345
346    /**
347     * Handles stored events publishing in a timer loop.
348     */
349    function _publishLoop() {
350        function isValid(evt) {
351            lastId = evt.id;
352            return evt.id > state.publish.last && config.filter(evt);
353        }
354        function onSuccess() {
355            state.publish.last = lastId;
356            state.publish.timer = setTimeout(_publishLoop, config.interval);
357        }
358        function onError(err) {
359            info("error publishing log events", err);
360            state.publish.timer = setTimeout(_publishLoop, config.interval);
361        }
362        var lastId = 0;
363        var events = state.history.filter(isValid);
364        if (events.length <= 0) {
365            state.publish.last = lastId;
366            state.publish.timer = null;
367        } else {
368            try {
369                config.publisher(events).then(onSuccess, onError);
370            } catch (e) {
371                onError(e);
372            }
373        }
374    }
375
376    /**
377     * Publishes events to the server.
378     */
379    function _publish(events) {
380        return $.ajax(config.url, {
381            method: "POST",
382            contentType: "application/json",
383            data: JSON.stringify(events)
384        });
385    }
386
387    /**
388     * Checks if an event is an error or a warning.
389     */
390    function _isErrorOrWarning(evt) {
391        return !!evt && /error|warn/.test(evt.level);
392    }
393
394    /**
395     * Creates a string representation (suitable for logging) for any value or
396     * object. The returned string is similar to a JSON representation of the
397     * value, but may be simplified for increased readability.
398     *
399     * @param {Object} val the value or object to convert
400     *
401     * @return {string} the string representation of the value
402     *
403     * @memberof RapidContext.Log
404     */
405    function stringify(val) {
406        var isObject = Object.prototype.toString.call(val) === "[object Object]";
407        var isArray = val instanceof Array;
408        var isSerializable = val && typeof(val.toJSON) === "function";
409        if (val && (isObject || isArray || isSerializable)) {
410            try {
411                return JSON.stringify(val, null, 2);
412            } catch (e) {
413                return String(val);
414            }
415        } else if (val instanceof Error) {
416            var parts = [val.toString()];
417            if (val.stack) {
418                var stack = String(val.stack).trim()
419                    .replace(val.toString() + "\n", "")
420                    .replace(/https?:.*\//g, "")
421                    .replace(/\?[^:\s]+:/g, ":")
422                    .replace(/@(.*)/mg, " ($1)")
423                    .replace(/^(\s+at\s+)?/mg, "    at ");
424                parts.push("\n", stack);
425            }
426            return parts.join("");
427        } else if (val && (val.nodeType === 1 || val.nodeType === 9)) {
428            var el = val.documentElement || val.document || val;
429            var xml = el.outerHTML || el.outerXML || el.xml || String(el);
430            var children = el.childNodes && el.childNodes.length;
431            return xml.replace(/>[^<]*/, children ? ">..." : "/>");
432        } else {
433            return String(val);
434        }
435    }
436
437    // Create namespaces
438    var RapidContext = window.RapidContext || (window.RapidContext = {});
439    var module = RapidContext.Log || (RapidContext.Log = {});
440
441    // Export namespace symbols
442    module.init = init;
443    module.history = history;
444    module.clear = clear;
445    module.level = level;
446    module.context = context;
447    module.error = error;
448    module.warn = warn;
449    module.info = info;
450    module.log = module.debug = module.trace = debug;
451    module.stringify = stringify;
452
453    // Install console loggers and global error handler
454    init();
455
456})(this);
457