Skip to content

Instantly share code, notes, and snippets.

@Kobe
Last active April 4, 2023 18:41
Show Gist options
  • Save Kobe/7ce463c23917a075cb9e89a939c2d6b8 to your computer and use it in GitHub Desktop.
Save Kobe/7ce463c23917a075cb9e89a939c2d6b8 to your computer and use it in GitHub Desktop.
var module = module || {};
window.module.config = window.module.config || {};
window.module.config.tracking = window.module.config.tracking || {};
function tracker(window, module, trackingOptions, $) {// NOSONAR
'use strict';
var LOG_EVENT_CONSOLE = 'console';
var LOG_EVENT_ERROR = 'error';
var LOG_EVENT_EVENT_TRACKING = 'event-tracking';
var LOG_EVENT_PAGE = 'page';
var WEBTREKK_TIMEOUT = 5000; // ms
var WEBTREKK_RETRY_DELAY = 100; // ms
var INTERNAL_LOGGING_URL = '/logClientInfo';
var LOCAL_LOGGER_NAME = 'ErrorTracking';
var LOGGING_LEVEL = 'WARN';
var defaultOptions = {
console: {
enabled: false, // {boolean} console output tracking, not debug
temporarilyDisabled: false // {boolean} for internal use only, to prevent console tracking of debug output
},
error: {
enabled: false, // {boolean}
debug: true, // {boolean} debug output of errors
filter: {
// {function} returns true if there are blacklisted values in the message
isBlacklisted: function(message) { return false; },
// {function} only capture errors from specific urls (e.g. no third party), use Regex to specify matched urls
isValidFile: function(file) { return true; },
// {function} prevents tracking errors of specific user agents
isValidBrowser: function() { return true; },
// {function} if true, filter errors in row 0, means CORS is disabled for that script, so we get no information
isValidRow: function(row) {
/* row === 0 means violation of cross domain policy
* see: - http://blog.errorception.com/2012/12/catching-cross-domain-js-errors.html
* - http://stackoverflow.com/questions/1653308/access-control-allow-origin-multiple-origin-domains
* - https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_settings_attributes
*/
return row > 0;
}
},
preventDefaultErrorHandler: false, // {boolean}, see: https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers.onerror
ratio: 100, // {number} percentage of users, where tracking is enabled, does not effect debug
stackTrace: {
trimLength: 5, // {number} trim stack trace to number of elements
trimString: false // {boolean|string|Array} to remove unuseful debug info and reduce data
}
},
event: {
enabled: false, // {boolean}
debug: true // {boolean}
},
timing: {},
send: {
webtrekk: function(params) {
if (isWebtrekkLoaded()) {
sendToWebtrekk();
} else {
var retryCount = 0,
webtrekkLoadedVerifier = setInterval(function () {
if (isWebtrekkLoaded()) {
sendToWebtrekk();
} else {
retryCount++;
}
if (retryCount >= WEBTREKK_TIMEOUT / WEBTREKK_RETRY_DELAY || isWebtrekkLoaded()) {
clearInterval(webtrekkLoadedVerifier);
}
}, WEBTREKK_RETRY_DELAY);
}
function isWebtrekkLoaded() {
return typeof wt === 'object' && typeof wt.sendinfo === 'function';
}
// collect and translate data for Webtrekk and send data to Webtrekk
function sendToWebtrekk() {// NOSONAR
var webtrekkEventParameter;
switch (params.type) {
case LOG_EVENT_CONSOLE:
case LOG_EVENT_ERROR:
if (params.name && params.value) {
webtrekkEventParameter = {
linkId: params.name,
customClickParameter: {3: params.value}
};
} else {
webtrekkEventParameter = {
linkId: 'error.unknown',
customClickParameter: {3: 'error.unknown | missing tracking parameter'}
};
}
break;
case LOG_EVENT_EVENT_TRACKING:
if (params.name) {
if (typeof params.value === 'undefined') {
webtrekkEventParameter = {linkId: params.name};
}
if (typeof params.value === 'object') {
var customClickParameter = {};
// fix object to support $.each, see: http://api.jquery.com/jquery.each/#entry-longdesc
if (params.value.length) {// NOSONAR
params.value.length_ = params.value.length;
delete params.value.length;
}
/*
map event param object to customClickParameter
valid Webtrekk Custom Event Parameter (add more values when necessary):
- Position = 1
- Element = 2
'invalid/used otherwise' Webtrekk Custom Event Parameter:
- JS Tracking = 3
- Scrollposition = 540
examples:
- $.publish('track.event', 'click');
- $.publish('track.event', ['click']);
- $.publish('track.event', ['leadout', { element: 'shop' }]);
- $.publish('track.event', ['foo', { element: 'shop', position: '10' }]);
- $.publish('track.event', ['foo', { element: 'shop', position: 'name' }]);
*/
$.each(params.value, function (key, value) {
switch (key.toLowerCase()) {// NOSONAR
case 'element':
customClickParameter[2] = value;
break;
case 'position':
customClickParameter[1] = value;
break;
default:
break;
}
});
//console.log({ linkId: params.name, customLinkParameter: customClickParameter });
webtrekkEventParameter = {
linkId: params.name,
customClickParameter: customClickParameter
};
}
} else {
webtrekkEventParameter = {
linkId: 'error.unknown',
customClickParameter: {3: 'error.unknown | missing tracking parameter'}
};
}
break;
default:
break;
}
if (webtrekkEventParameter) {
wt.sendinfo(webtrekkEventParameter);
}
}
},
internal: function(params) {
var wanted_log_sources = [LOG_EVENT_ERROR];
if ($.inArray(params.type, wanted_log_sources) === -1 ||
typeof params.value === 'undefined' ||
!params.value.length) {
return;
}
$.ajax({
method: 'POST',
url: INTERNAL_LOGGING_URL,
data: JSON.stringify({'message': params.value, 'logger': LOCAL_LOGGER_NAME, 'level': LOGGING_LEVEL}),
contentType: 'application/json'
});
}
}
},
options = {};
/**
* set initial options
*/
function initOptions() {
$.extend(true, options, defaultOptions, trackingOptions);
// enable/disable tracking, related to ratio option
options.error.enabled = options.error.enabled && (Math.floor(Math.random() * 100 / options.error.ratio) === 0);
// enable/disable console output tracking, it's not for debug output
options.console.enabled = 'console' in window && options.console.enabled;
}
/**
* sends tracking information
*
* we use an optional function for sending to be more flexible with the tracking
*
* @todo - add error event array to collect all errors and send them later, could prevent sending to many events via aggregation of errors
* - maybe move the ready() delay to the optional function, to be more flexible with other trackings
* @param params
*/
function sendTracking(params){
$.each(options.send, function (id, trackingFunction) {
trackingFunction(params);
});
}
/**
* console output of tracking informations
*
* WARNING: When using the firebug console, this function will throw an exception 'dbg is not defined'. It's a
* bug with firebug itself. There is no problem when using the built-in developer tools of Firefox.
*
* @param message
* @param type
*/
function debugTracking(message, type){
options.console.temporarilyDisabled = true; // prevent console tracking of debug output
if ('console' in window) {
if (!console.debug) {
console.debug = console.log;
}
if (!console.error) {
console.error = console.log;
}
switch (type) {
case 'debug':
console.debug(message);
break;
case 'error':
console.error(message);
break;
case 'info':
console.error(message);
break;
default:
console.log(message);
}
}
options.console.temporarilyDisabled = false;
}
/**
* cleanup & normalize stack traces for tracking
*
* @todo - allow to remove multiple strings via array
*
* @param [Object} errorObject
* @param {String} removeString
* @returns {String|null}
*/
function normalizeStackTrace(errorObject, removeString) {
var stackTraceString,
trimLength = options.error.stackTrace.trimLength || 100,
trimString = options.error.stackTrace.trimString;
// return if there is no stacktrace
if (typeof errorObject === 'undefined' || errorObject === null || typeof errorObject.stack !== 'string') {
return null;
} else {
stackTraceString = errorObject.stack;
}
// remove a given string
if (removeString) {
stackTraceString = stackTraceString.replace(new RegExp(removeString,'g'), ''); // see: http://stackoverflow.com/a/494046/2590616
}
// remove error message from stack trace (e.g. in Chrome)
if (stackTraceString.indexOf(errorObject.message) > -1) {
stackTraceString = stackTraceString.substr(stackTraceString.indexOf(errorObject.message)+errorObject.message.length);
}
// cleanup string
stackTraceString = stackTraceString
// trim newline & whitespace, see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim#Polyfill
.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '')
.replace(/\/</, '') // remove useless '/<'
//.replace(/\/:/, ':') // remove slash from '/:'
.replace(/at | at |\)/gm, '').replace(/ \(/gm, '@').replace(/@/g, ' @ ') // create similar output for chrome & firefox
.split('\n') // split at newline
.splice(0, trimLength) // trim length of stack trace elements
.join(' > ') // join with ' > ' as separator
.replace(/ +/g, ' ') // remove multiple whitepaces, see: http://stackoverflow.com/a/1981366/2590616
.replace(/^ @ /, '') // remove trailing ' @ '
.replace(/ > @ /g, ' > '); // remove useless ' > @ ' combination
// trim stacktrace to remove unuseful debug info and reduce data
$.each($.isArray(trimString) ? trimString : [trimString], function(index, trimString) {// NOSONAR
var trimPosition = (typeof trimString === 'string' && stackTraceString.indexOf(trimString) > 0) ?
stackTraceString.indexOf(trimString) : stackTraceString.length;
stackTraceString = stackTraceString.substr(0, trimPosition);
});
return stackTraceString;
}
/**
* try to normalize the output of the error object across different browsers
*
* @param {Error} errorObject
*/
function normalizeErrorObject(errorObject) {
if (typeof errorObject === 'object' && errorObject !== null && typeof errorObject.stack === 'string') {
errorObject.stack = normalizeStackTrace(errorObject);
// check if filename, line number or column number are empty, then extract it from stacktrace
if (!errorObject.fileName || !errorObject.lineNumber || !errorObject.columnNumber) {
var errorArray = errorObject.stack.split(' > ')[0].split(':'); // get first stack trace entry and split it into details
if (errorArray.length >= 3) {
errorObject.columnNumber = errorArray.pop(); // last item is column
errorObject.lineNumber = errorArray.pop(); // (second) last item is line number
errorObject.fileName = errorArray.join(':').split(' ').pop(); // rest should be filename
}
}
}
return errorObject;
}
/**
* Parses error messages of custom errors
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
*
* @param message
* @returns {string|Error|Array}
*/
function parseCustomErrorMessage(message) {
var errorMessage = '',
errorObject = {};
if (typeof message === 'string') {
errorMessage = message;
}
if (typeof message === 'object') {
if (message instanceof Error) {
errorObject = message;
// allows to use an array as error message in new Error
// new Error() joins an array with ',', so we split and rejoin it here with ' | '
// but we don't do it if there is ', ' in the message
if (errorObject.message.indexOf(',') !== errorObject.message.indexOf(', ')) {
errorObject.message = errorObject.message.split(',').join(' | ');
}
errorObject = normalizeErrorObject(errorObject);
// to reduce string length and redundancy
// we use stack trace only and no errorObject.fileName, errorObject.lineNumber or errorObject.columnNumber
if (errorObject.stack) {
errorMessage = [errorObject.message, errorObject.stack].join(' | ');
} else {
errorMessage = errorObject.message;
}
}
if ($.isArray(message)) {
errorMessage = message.join(' | ');
}
}
return errorMessage;
}
/**
* track errors
*
* - tracking of errors is done decupled via publish/subscribe
* - filtering of errors should be done in the publish/capturing part, formatting of the errors on the subscribe/tracking part
* - the data from '_event_' should be ignored, it's a leftover of the tiny pub/sub handling
*
* @param name
* @returns {Function}
*/
function trackError(name) {
/**
* handle errors happening while ajax calls
*
* @param _event_
* @param {jQXHR} jqxhr
* @param {object} settings
* @param {Error} thrownError
*/
function trackAjaxError(_event_, jqxhr, settings, thrownError){
var errorName = 'error.' + name,
errorMessage = [errorName, thrownError + ' (' + jqxhr.status + ')', settings.type, settings.url].join(' | ');
if (options.error.debug) {
debugTracking(errorMessage);
}
if (options.error.enabled) {
sendTracking({type: LOG_EVENT_ERROR, name: errorName, value: errorMessage});
}
}
/**
* handles several custom error types:
* error.debug: handle errors fired on purpose for debug and qa reasons
* error.form: form errors
* error.ui: errors shown to the user (e.g. 'We are unable to...')
*
* @example $.publish('track.error.debug', new Error(['foo', 'bar'])); // allows stack trace output
* @example $.publish('track.error.form', 'foo bar'));
* @example $.publish('track.error.ui', ['foo', 'bar']);
*
* @param _event_
* @param {string|Error|Array} message
*/
function trackCustomError(_event_, message) {
var errorName = 'error.' + name,
errorMessage;
// check if we have multiple messages instead of one
if (arguments.length > 2) {// NOSONAR
message = Array.prototype.slice.call(arguments); // overwrite message with an array of messages
message.shift(); // remove _event_ object
}
message = parseCustomErrorMessage(message);
errorMessage = [errorName, message].join(' | ');
if (options.error.debug) {
debugTracking(errorMessage, name === 'debug' ? 'debug' : '');
}
if (options.error.enabled) {
sendTracking({type: LOG_EVENT_ERROR, name: errorName, value: errorMessage});
}
}
/**
* handle generic onerror errors not captured otherwise
*
* @param _event_
* @param {string} message
* @param {string} file
* @param {number} row
* @param {number} column
* @param {Error} errorObject
*/
function trackOnError(_event_, message, file, row, column, errorObject){
var errorName = 'error.' + name,
fileOrigin = file ? (file.substr(0, file.indexOf(file.split('/')[2]) + file.split('/')[2].length)) : '',
errorMessage = [errorName, message];
// add stack trace to error message
if (typeof errorObject !== 'undefined' && errorObject !== null && typeof errorObject.stack === 'string') {
errorMessage.push(normalizeStackTrace(errorObject, fileOrigin));
}
errorMessage = errorMessage.join(' | ');
if (!options.error.filter.isBlacklisted(errorMessage)) {
if (options.error.debug) {
debugTracking(errorMessage, 'error');
}
if (options.error.enabled) {
sendTracking({type: LOG_EVENT_ERROR, name: errorName, value: errorMessage});
}
}
}
switch(name) {
case 'ajax':
return trackAjaxError;
case 'debug':
return trackCustomError;
case 'form':
return trackCustomError;
case 'onerror':
return trackOnError;
case 'ui':
return trackCustomError;
default:
// unknown errors are impossible here
}
}
/**
* captures all jQuery AJAX Errors
*/
function initTrackAjaxError() {
$(document).ajaxError(function(event, jqxhr, settings, thrownError) {
// jqxhr.status === 0 means the request has been canceled by the browser (e.g because of page reload), we don't care about that
var requestHasBeenCanceled = jqxhr.status === 0;
if (!requestHasBeenCanceled) {
$.publish('track.error.ajax', [jqxhr, settings, thrownError]);
}
});
}
/**
* captures all generic script errors via windows.onerror
*
* @see http://stackoverflow.com/questions/951791/javascript-global-error-handling
* https://bugsnag.com/blog/script-error
*
* @todo - filtering of useless errors (analyze captured errors before adding more filters)
*/
function initTrackOnError() {
window.onerror = function onError(message, file, row, column, errorObject){
if (options.error.filter.isValidRow(row) && options.error.filter.isValidFile(file)) {
$.publish('track.error.onerror', [message, file, row, column, errorObject]);
}
return options.error.preventDefaultErrorHandler;
};
}
/**
* handle error subscription and initialize error tracking
*/
function initTrackError() {
if (options.error.filter.isValidBrowser()) {
$.each(['ajax', 'debug', 'form', 'onerror', 'ui'], function(index, name) {
$.subscribe('track.error.'+name, trackError(name));
});
initTrackAjaxError();
initTrackOnError();
}
}
/**
* capture console output
*
* @todo - make the output more useful, because it will be tracked too much at the moment, even useless browser error output errors via console.error
* - implement ratio
*
* @see http://stackoverflow.com/a/23679915/2590616
* https://msdn.microsoft.com/en-us/library/windows/apps/hh696634.aspx
* http://getfirebug.com/logging
*/
function trackConsole() {
$.each(['assert', 'cd', 'clear', 'count', 'countReset', 'debug', 'dir', 'dirxml', 'error',
'exception', 'group', 'groupCollapsed', 'groupEnd', 'info', 'log',
'markTimeline', 'profile', 'profileEnd', 'select', 'table', 'time', 'timeEnd',
'timeStamp', 'timeline', 'timelineEnd', 'trace', 'warn'
], function(index, value) {
window.console['_'+value] = window.console[value]; // make a reference to prevent recursion!
window.console[value] = function() {
var consoleName = 'console.' + value,
consoleMessage = [consoleName].concat(Array.prototype.slice.call(arguments)).join(' | ');// NOSONAR
// do not track console output, e.g. for internal debug output
if (!options.console.temporarilyDisabled) {
sendTracking({type: LOG_EVENT_CONSOLE, name: consoleName, value: consoleMessage});
}
// call a reference of the function to prevent recursion
window.console['_'+value].apply(console, Array.prototype.slice.call(arguments));
return arguments;
};
});
}
/**
*
*/
function initTrackConsole() {
if (options.console.enabled) {
trackConsole();
}
}
/**
* track event
*
* @todo - check current webtrekk events compatibility
* - use convertString() from _ipc-webtrekk.js (maybe in options.send())
*
* @param {string} name
* @returns {Function}
*/
function trackEvent(name){
return function(_event_, eventName, eventValue){
if (options.event.debug) {
var eventMessage = [name, eventName];
if (eventValue) {
eventMessage.push(JSON.stringify(eventValue));
}
eventMessage = eventMessage.join(' | ');
debugTracking(eventMessage, 'info');
}
if (options.event.enabled) {
if (name === 'event') {
sendTracking({type: LOG_EVENT_EVENT_TRACKING, name: eventName, value: eventValue});
}
if (name === 'page') {
sendTracking({type: LOG_EVENT_PAGE, name: eventName});
}
}
};
}
/**
*
*/
function initTrackEvent() {
$.each(['event', 'page', 'session'], function(index, name) {
$.subscribe('track.'+name, trackEvent(name));
});
$.subscribe('track.event', trackEvent('event'));
}
/**
* track timing (RUM)
*
* @todo replace rum tracking script
* @returns {Function}
*/
function trackTiming(name){
return function(_event_, data){
if (options.debug) console.info(name, JSON.stringify(data));
};
}
/**
*
*/
function initTrackTiming() {
$.subscribe('track.timing', trackTiming('track.timing'));
}
/**
*
*/
function init() {
initOptions();
initTrackError();
initTrackConsole();
initTrackEvent();
initTrackTiming();
}
init();
}
tracker(window, window.module, window.module.config.tracking, jQuery);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment