Source RapidContext_Log.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2024 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        window.onerror = 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        url = url.replace(document.baseURI || "", "");
273        var location = [url, line, col].filter(Boolean).join(":");
274        error(msg || "Uncaught error", location, err);
275        return true;
276    }
277
278    /**
279     * Logs a message to one of the console loggers.
280     *
281     * @param {string} level the log level (i.e. function 'error', 'warn'...)
282     * @param {Array} args the log message & data (as raw objects)
283     */
284    function _log(level, args) {
285        var logger = backup[level];
286        if (typeof(logger) === "function") {
287            _group(state.context);
288            logger.apply(window.console, args);
289        }
290    }
291
292    /**
293     * Calls the `console.group` and `console.groupEnd` functions (if
294     * they exist) to change the group label (if needed).
295     *
296     * @param {string} label the log context label
297     */
298    function _group(label) {
299        var console = window.console;
300        if (console._group !== label) {
301            if (console._group) {
302                delete console._group;
303                if (typeof(console.groupEnd) === "function") {
304                    console.groupEnd();
305                }
306            }
307            if (label) {
308                console._group = label;
309                if (typeof(console.group) === "function") {
310                    console.group(label + ":");
311                }
312            }
313        }
314    }
315
316    /**
317     * Stores a log message to the history.
318     *
319     * @param {string} level the message log level
320     * @param {Array} args the log message arguments (as strings)
321     */
322    function _store(level, args) {
323        var m = 1;
324        for (; m < args.length; m++) {
325            if (args[m] && args[m].indexOf("\n") >= 0) {
326                break;
327            }
328        }
329        state.history.push({
330            id: ++state.count,
331            time: new Date(),
332            level: level,
333            context: state.context,
334            message: args.slice(0, m).join(" "),
335            data: (m < args.length) ? args.slice(m).join("\n") : null
336        });
337        state.history.splice(0, Math.max(0, state.history.length - 100));
338        if (!state.publish.timer) {
339            state.publish.timer = setTimeout(_publishLoop, 100);
340        }
341    }
342
343    /**
344     * Handles stored events publishing in a timer loop.
345     */
346    function _publishLoop() {
347        function isValid(evt) {
348            lastId = evt.id;
349            return evt.id > state.publish.last && config.filter(evt);
350        }
351        function onSuccess() {
352            state.publish.last = lastId;
353            state.publish.timer = setTimeout(_publishLoop, config.interval);
354        }
355        function onError(err) {
356            info("error publishing log events", err);
357            state.publish.timer = setTimeout(_publishLoop, config.interval);
358        }
359        var lastId = 0;
360        var events = state.history.filter(isValid);
361        if (events.length <= 0) {
362            state.publish.last = lastId;
363            state.publish.timer = null;
364        } else {
365            try {
366                config.publisher(events).then(onSuccess, onError);
367            } catch (e) {
368                onError(e);
369            }
370        }
371    }
372
373    /**
374     * Publishes events to the server.
375     */
376    function _publish(events) {
377        return $.ajax(config.url, {
378            method: "POST",
379            contentType: "application/json",
380            data: JSON.stringify(events)
381        });
382    }
383
384    /**
385     * Checks if an event is an error or a warning.
386     */
387    function _isErrorOrWarning(evt) {
388        return !!evt && /error|warn/.test(evt.level);
389    }
390
391    /**
392     * Creates a string representation (suitable for logging) for any value or
393     * object. The returned string is similar to a JSON representation of the
394     * value, but may be simplified for increased readability.
395     *
396     * @param {Object} val the value or object to convert
397     *
398     * @return {string} the string representation of the value
399     *
400     * @memberof RapidContext.Log
401     */
402    function stringify(val) {
403        var isObject = Object.prototype.toString.call(val) === "[object Object]";
404        var isArray = val instanceof Array;
405        var isSerializable = val && typeof(val.toJSON) === "function";
406        if (val && (isObject || isArray || isSerializable)) {
407            try {
408                return JSON.stringify(val, null, 2);
409            } catch (e) {
410                return String(val);
411            }
412        } else if (val instanceof Error) {
413            var parts = [val.toString()];
414            if (val.stack) {
415                var stack = String(val.stack).trim()
416                    .replace(val.toString() + "\n", "")
417                    .replace(/https?:.*\//g, "")
418                    .replace(/\?[^:\s]+:/g, ":")
419                    .replace(/@(.*)/mg, " ($1)")
420                    .replace(/^(\s+at\s+)?/mg, "    at ");
421                parts.push("\n", stack);
422            }
423            return parts.join("");
424        } else if (val && (val.nodeType === 1 || val.nodeType === 9)) {
425            var el = val.documentElement || val.document || val;
426            var xml = el.outerHTML || el.outerXML || el.xml || String(el);
427            var children = el.childNodes && el.childNodes.length;
428            return xml.replace(/>[^<]*/, children ? ">..." : "/>");
429        } else {
430            return String(val);
431        }
432    }
433
434    // Create namespaces
435    var RapidContext = window.RapidContext || (window.RapidContext = {});
436    var module = RapidContext.Log || (RapidContext.Log = {});
437
438    // Export namespace symbols
439    module.init = init;
440    module.history = history;
441    module.clear = clear;
442    module.level = level;
443    module.context = context;
444    module.error = error;
445    module.warn = warn;
446    module.info = info;
447    module.log = module.debug = module.trace = debug;
448    module.stringify = stringify;
449
450    // Install console loggers and global error handler
451    init();
452
453})(this);
454