var _ = require('../utility'); var headers = require('../utility/headers'); var replace = require('../utility/replace'); var scrub = require('../scrub'); var urlparser = require('./url'); var domUtil = require('./domUtility'); var defaults = { network: true, networkResponseHeaders: false, networkResponseBody: false, networkRequestHeaders: false, networkRequestBody: false, networkErrorOnHttp5xx: false, networkErrorOnHttp4xx: false, networkErrorOnHttp0: false, log: true, dom: true, navigation: true, connectivity: true, contentSecurityPolicy: true, errorOnContentSecurityPolicy: false, }; function restore(replacements, type) { var b; while (replacements[type].length) { b = replacements[type].shift(); b[0][b[1]] = b[2]; } } function nameFromDescription(description) { if (!description || !description.attributes) { return null; } var attrs = description.attributes; for (var a = 0; a < attrs.length; ++a) { if (attrs[a].key === 'name') { return attrs[a].value; } } return null; } function defaultValueScrubber(scrubFields) { var patterns = []; for (var i = 0; i < scrubFields.length; ++i) { patterns.push(new RegExp(scrubFields[i], 'i')); } return function (description) { var name = nameFromDescription(description); if (!name) { return false; } for (var i = 0; i < patterns.length; ++i) { if (patterns[i].test(name)) { return true; } } return false; }; } function Instrumenter(options, telemeter, rollbar, _window, _document) { this.options = options; var autoInstrument = options.autoInstrument; if (options.enabled === false || autoInstrument === false) { this.autoInstrument = {}; } else { if (!_.isType(autoInstrument, 'object')) { autoInstrument = defaults; } this.autoInstrument = _.merge(defaults, autoInstrument); } this.scrubTelemetryInputs = !!options.scrubTelemetryInputs; this.telemetryScrubber = options.telemetryScrubber; this.defaultValueScrubber = defaultValueScrubber(options.scrubFields); this.telemeter = telemeter; this.rollbar = rollbar; this.diagnostic = rollbar.client.notifier.diagnostic; this._window = _window || {}; this._document = _document || {}; this.replacements = { network: [], log: [], navigation: [], connectivity: [], }; this.eventRemovers = { dom: [], connectivity: [], contentsecuritypolicy: [], }; this._location = this._window.location; this._lastHref = this._location && this._location.href; } Instrumenter.prototype.configure = function (options) { this.options = _.merge(this.options, options); var autoInstrument = options.autoInstrument; var oldSettings = _.merge(this.autoInstrument); if (options.enabled === false || autoInstrument === false) { this.autoInstrument = {}; } else { if (!_.isType(autoInstrument, 'object')) { autoInstrument = defaults; } this.autoInstrument = _.merge(defaults, autoInstrument); } this.instrument(oldSettings); if (options.scrubTelemetryInputs !== undefined) { this.scrubTelemetryInputs = !!options.scrubTelemetryInputs; } if (options.telemetryScrubber !== undefined) { this.telemetryScrubber = options.telemetryScrubber; } }; // eslint-disable-next-line complexity Instrumenter.prototype.instrument = function (oldSettings) { if (this.autoInstrument.network && !(oldSettings && oldSettings.network)) { this.instrumentNetwork(); } else if ( !this.autoInstrument.network && oldSettings && oldSettings.network ) { this.deinstrumentNetwork(); } if (this.autoInstrument.log && !(oldSettings && oldSettings.log)) { this.instrumentConsole(); } else if (!this.autoInstrument.log && oldSettings && oldSettings.log) { this.deinstrumentConsole(); } if (this.autoInstrument.dom && !(oldSettings && oldSettings.dom)) { this.instrumentDom(); } else if (!this.autoInstrument.dom && oldSettings && oldSettings.dom) { this.deinstrumentDom(); } if ( this.autoInstrument.navigation && !(oldSettings && oldSettings.navigation) ) { this.instrumentNavigation(); } else if ( !this.autoInstrument.navigation && oldSettings && oldSettings.navigation ) { this.deinstrumentNavigation(); } if ( this.autoInstrument.connectivity && !(oldSettings && oldSettings.connectivity) ) { this.instrumentConnectivity(); } else if ( !this.autoInstrument.connectivity && oldSettings && oldSettings.connectivity ) { this.deinstrumentConnectivity(); } if ( this.autoInstrument.contentSecurityPolicy && !(oldSettings && oldSettings.contentSecurityPolicy) ) { this.instrumentContentSecurityPolicy(); } else if ( !this.autoInstrument.contentSecurityPolicy && oldSettings && oldSettings.contentSecurityPolicy ) { this.deinstrumentContentSecurityPolicy(); } }; Instrumenter.prototype.deinstrumentNetwork = function () { restore(this.replacements, 'network'); }; Instrumenter.prototype.instrumentNetwork = function () { var self = this; function wrapProp(prop, xhr) { if (prop in xhr && _.isFunction(xhr[prop])) { replace(xhr, prop, function (orig) { return self.rollbar.wrap(orig); }); } } if ('XMLHttpRequest' in this._window) { var xhrp = this._window.XMLHttpRequest.prototype; replace( xhrp, 'open', function (orig) { return function (method, url) { var isUrlObject = _isUrlObject(url); if (_.isType(url, 'string') || isUrlObject) { url = isUrlObject ? url.toString() : url; if (this.__rollbar_xhr) { this.__rollbar_xhr.method = method; this.__rollbar_xhr.url = url; this.__rollbar_xhr.status_code = null; this.__rollbar_xhr.start_time_ms = _.now(); this.__rollbar_xhr.end_time_ms = null; } else { this.__rollbar_xhr = { method: method, url: url, status_code: null, start_time_ms: _.now(), end_time_ms: null, }; } } return orig.apply(this, arguments); }; }, this.replacements, 'network', ); replace( xhrp, 'setRequestHeader', function (orig) { return function (header, value) { // If xhr.open is async, __rollbar_xhr may not be initialized yet. if (!this.__rollbar_xhr) { this.__rollbar_xhr = {}; } if (_.isType(header, 'string') && _.isType(value, 'string')) { if (self.autoInstrument.networkRequestHeaders) { if (!this.__rollbar_xhr.request_headers) { this.__rollbar_xhr.request_headers = {}; } this.__rollbar_xhr.request_headers[header] = value; } // We want the content type even if request header telemetry is off. if (header.toLowerCase() === 'content-type') { this.__rollbar_xhr.request_content_type = value; } } return orig.apply(this, arguments); }; }, this.replacements, 'network', ); replace( xhrp, 'send', function (orig) { /* eslint-disable no-unused-vars */ return function (data) { /* eslint-enable no-unused-vars */ var xhr = this; function onreadystatechangeHandler() { if (xhr.__rollbar_xhr) { if (xhr.__rollbar_xhr.status_code === null) { xhr.__rollbar_xhr.status_code = 0; if (self.autoInstrument.networkRequestBody) { xhr.__rollbar_xhr.request = data; } xhr.__rollbar_event = self.captureNetwork( xhr.__rollbar_xhr, 'xhr', undefined, ); } if (xhr.readyState < 2) { xhr.__rollbar_xhr.start_time_ms = _.now(); } if (xhr.readyState > 3) { xhr.__rollbar_xhr.end_time_ms = _.now(); var headers = null; xhr.__rollbar_xhr.response_content_type = xhr.getResponseHeader('Content-Type'); if (self.autoInstrument.networkResponseHeaders) { var headersConfig = self.autoInstrument.networkResponseHeaders; headers = {}; try { var header, i; if (headersConfig === true) { var allHeaders = xhr.getAllResponseHeaders(); if (allHeaders) { var arr = allHeaders.trim().split(/[\r\n]+/); var parts, value; for (i = 0; i < arr.length; i++) { parts = arr[i].split(': '); header = parts.shift(); value = parts.join(': '); headers[header] = value; } } } else { for (i = 0; i < headersConfig.length; i++) { header = headersConfig[i]; headers[header] = xhr.getResponseHeader(header); } } } catch (e) { /* we ignore the errors here that could come from different * browser issues with the xhr methods */ } } var body = null; if (self.autoInstrument.networkResponseBody) { try { body = xhr.responseText; } catch (e) { /* ignore errors from reading responseText */ } } var response = null; if (body || headers) { response = {}; if (body) { if ( self.isJsonContentType( xhr.__rollbar_xhr.response_content_type, ) ) { response.body = self.scrubJson(body); } else { response.body = body; } } if (headers) { response.headers = headers; } } if (response) { xhr.__rollbar_xhr.response = response; } try { var code = xhr.status; code = code === 1223 ? 204 : code; xhr.__rollbar_xhr.status_code = code; xhr.__rollbar_event.level = self.telemeter.levelFromStatus(code); self.errorOnHttpStatus(xhr.__rollbar_xhr); } catch (e) { /* ignore possible exception from xhr.status */ } } } } wrapProp('onload', xhr); wrapProp('onerror', xhr); wrapProp('onprogress', xhr); if ( 'onreadystatechange' in xhr && _.isFunction(xhr.onreadystatechange) ) { replace(xhr, 'onreadystatechange', function (orig) { return self.rollbar.wrap( orig, undefined, onreadystatechangeHandler, ); }); } else { xhr.onreadystatechange = onreadystatechangeHandler; } if (xhr.__rollbar_xhr && self.trackHttpErrors()) { xhr.__rollbar_xhr.stack = new Error().stack; } return orig.apply(this, arguments); }; }, this.replacements, 'network', ); } if ('fetch' in this._window) { replace( this._window, 'fetch', function (orig) { /* eslint-disable no-unused-vars */ return function (fn, t) { /* eslint-enable no-unused-vars */ var args = new Array(arguments.length); for (var i = 0, len = args.length; i < len; i++) { args[i] = arguments[i]; } var input = args[0]; var method = 'GET'; var url; var isUrlObject = _isUrlObject(input); if (_.isType(input, 'string') || isUrlObject) { url = isUrlObject ? input.toString() : input; } else if (input) { url = input.url; if (input.method) { method = input.method; } } if (args[1] && args[1].method) { method = args[1].method; } var metadata = { method: method, url: url, status_code: null, start_time_ms: _.now(), end_time_ms: null, }; if (args[1] && args[1].headers) { // Argument may be a Headers object, or plain object. Ensure here that // we are working with a Headers object with case-insensitive keys. var reqHeaders = headers(args[1].headers); metadata.request_content_type = reqHeaders.get('Content-Type'); if (self.autoInstrument.networkRequestHeaders) { metadata.request_headers = self.fetchHeaders( reqHeaders, self.autoInstrument.networkRequestHeaders, ); } } if (self.autoInstrument.networkRequestBody) { if (args[1] && args[1].body) { metadata.request = args[1].body; } else if ( args[0] && !_.isType(args[0], 'string') && args[0].body ) { metadata.request = args[0].body; } } self.captureNetwork(metadata, 'fetch', undefined); if (self.trackHttpErrors()) { metadata.stack = new Error().stack; } // Start our handler before returning the promise. This allows resp.clone() // to execute before other handlers touch the response. return orig.apply(this, args).then(function (resp) { metadata.end_time_ms = _.now(); metadata.status_code = resp.status; metadata.response_content_type = resp.headers.get('Content-Type'); var headers = null; if (self.autoInstrument.networkResponseHeaders) { headers = self.fetchHeaders( resp.headers, self.autoInstrument.networkResponseHeaders, ); } var body = null; if (self.autoInstrument.networkResponseBody) { if (typeof resp.text === 'function') { // Response.text() is not implemented on some platforms // The response must be cloned to prevent reading (and locking) the original stream. // This must be done before other handlers touch the response. body = resp.clone().text(); //returns a Promise } } if (headers || body) { metadata.response = {}; if (body) { // Test to ensure body is a Promise, which it should always be. if (typeof body.then === 'function') { body.then(function (text) { if ( text && self.isJsonContentType(metadata.response_content_type) ) { metadata.response.body = self.scrubJson(text); } else { metadata.response.body = text; } }); } else { metadata.response.body = body; } } if (headers) { metadata.response.headers = headers; } } self.errorOnHttpStatus(metadata); return resp; }); }; }, this.replacements, 'network', ); } }; Instrumenter.prototype.captureNetwork = function ( metadata, subtype, rollbarUUID, ) { if ( metadata.request && this.isJsonContentType(metadata.request_content_type) ) { metadata.request = this.scrubJson(metadata.request); } return this.telemeter.captureNetwork(metadata, subtype, rollbarUUID); }; Instrumenter.prototype.isJsonContentType = function (contentType) { return contentType && _.isType(contentType, 'string') && contentType.toLowerCase().includes('json') ? true : false; }; Instrumenter.prototype.scrubJson = function (json) { return JSON.stringify(scrub(JSON.parse(json), this.options.scrubFields)); }; Instrumenter.prototype.fetchHeaders = function (inHeaders, headersConfig) { var outHeaders = {}; try { var i; if (headersConfig === true) { if (typeof inHeaders.entries === 'function') { // Headers.entries() is not implemented in IE var allHeaders = inHeaders.entries(); var currentHeader = allHeaders.next(); while (!currentHeader.done) { outHeaders[currentHeader.value[0]] = currentHeader.value[1]; currentHeader = allHeaders.next(); } } } else { for (i = 0; i < headersConfig.length; i++) { var header = headersConfig[i]; outHeaders[header] = inHeaders.get(header); } } } catch (e) { /* ignore probable IE errors */ } return outHeaders; }; Instrumenter.prototype.trackHttpErrors = function () { return ( this.autoInstrument.networkErrorOnHttp5xx || this.autoInstrument.networkErrorOnHttp4xx || this.autoInstrument.networkErrorOnHttp0 ); }; Instrumenter.prototype.errorOnHttpStatus = function (metadata) { var status = metadata.status_code; if ( (status >= 500 && this.autoInstrument.networkErrorOnHttp5xx) || (status >= 400 && this.autoInstrument.networkErrorOnHttp4xx) || (status === 0 && this.autoInstrument.networkErrorOnHttp0) ) { var error = new Error('HTTP request failed with Status ' + status); error.stack = metadata.stack; this.rollbar.error(error, { skipFrames: 1 }); } }; Instrumenter.prototype.deinstrumentConsole = function () { if (!('console' in this._window && this._window.console.log)) { return; } var b; while (this.replacements['log'].length) { b = this.replacements['log'].shift(); this._window.console[b[0]] = b[1]; } }; Instrumenter.prototype.instrumentConsole = function () { if (!('console' in this._window && this._window.console.log)) { return; } var self = this; var c = this._window.console; function wrapConsole(method) { 'use strict'; // See https://github.com/rollbar/rollbar.js/pull/778 var orig = c[method]; var origConsole = c; var level = method === 'warn' ? 'warning' : method; c[method] = function () { var args = Array.prototype.slice.call(arguments); var message = _.formatArgsAsString(args); self.telemeter.captureLog(message, level); if (orig) { Function.prototype.apply.call(orig, origConsole, args); } }; self.replacements['log'].push([method, orig]); } var methods = ['debug', 'info', 'warn', 'error', 'log']; try { for (var i = 0, len = methods.length; i < len; i++) { wrapConsole(methods[i]); } } catch (e) { this.diagnostic.instrumentConsole = { error: e.message }; } }; Instrumenter.prototype.deinstrumentDom = function () { if (!('addEventListener' in this._window || 'attachEvent' in this._window)) { return; } this.removeListeners('dom'); }; Instrumenter.prototype.instrumentDom = function () { if (!('addEventListener' in this._window || 'attachEvent' in this._window)) { return; } var clickHandler = this.handleClick.bind(this); var blurHandler = this.handleBlur.bind(this); this.addListener('dom', this._window, 'click', 'onclick', clickHandler, true); this.addListener( 'dom', this._window, 'blur', 'onfocusout', blurHandler, true, ); }; Instrumenter.prototype.handleClick = function (evt) { try { var e = domUtil.getElementFromEvent(evt, this._document); var hasTag = e && e.tagName; var anchorOrButton = domUtil.isDescribedElement(e, 'a') || domUtil.isDescribedElement(e, 'button'); if ( hasTag && (anchorOrButton || domUtil.isDescribedElement(e, 'input', ['button', 'submit'])) ) { this.captureDomEvent('click', e); } else if (domUtil.isDescribedElement(e, 'input', ['checkbox', 'radio'])) { this.captureDomEvent('input', e, e.value, e.checked); } } catch (exc) { // TODO: Not sure what to do here } }; Instrumenter.prototype.handleBlur = function (evt) { try { var e = domUtil.getElementFromEvent(evt, this._document); if (e && e.tagName) { if (domUtil.isDescribedElement(e, 'textarea')) { this.captureDomEvent('input', e, e.value); } else if ( domUtil.isDescribedElement(e, 'select') && e.options && e.options.length ) { this.handleSelectInputChanged(e); } else if ( domUtil.isDescribedElement(e, 'input') && !domUtil.isDescribedElement(e, 'input', [ 'button', 'submit', 'hidden', 'checkbox', 'radio', ]) ) { this.captureDomEvent('input', e, e.value); } } } catch (exc) { // TODO: Not sure what to do here } }; Instrumenter.prototype.handleSelectInputChanged = function (elem) { if (elem.multiple) { for (var i = 0; i < elem.options.length; i++) { if (elem.options[i].selected) { this.captureDomEvent('input', elem, elem.options[i].value); } } } else if (elem.selectedIndex >= 0 && elem.options[elem.selectedIndex]) { this.captureDomEvent('input', elem, elem.options[elem.selectedIndex].value); } }; Instrumenter.prototype.captureDomEvent = function ( subtype, element, value, isChecked, ) { if (value !== undefined) { if ( this.scrubTelemetryInputs || domUtil.getElementType(element) === 'password' ) { value = '[scrubbed]'; } else { var description = domUtil.describeElement(element); if (this.telemetryScrubber) { if (this.telemetryScrubber(description)) { value = '[scrubbed]'; } } else if (this.defaultValueScrubber(description)) { value = '[scrubbed]'; } } } var elementString = domUtil.elementArrayToString( domUtil.treeToArray(element), ); this.telemeter.captureDom(subtype, elementString, value, isChecked); }; Instrumenter.prototype.deinstrumentNavigation = function () { var chrome = this._window.chrome; var chromePackagedApp = chrome && chrome.app && chrome.app.runtime; // See https://github.com/angular/angular.js/pull/13945/files var hasPushState = !chromePackagedApp && this._window.history && this._window.history.pushState; if (!hasPushState) { return; } restore(this.replacements, 'navigation'); }; Instrumenter.prototype.instrumentNavigation = function () { var chrome = this._window.chrome; var chromePackagedApp = chrome && chrome.app && chrome.app.runtime; // See https://github.com/angular/angular.js/pull/13945/files var hasPushState = !chromePackagedApp && this._window.history && this._window.history.pushState; if (!hasPushState) { return; } var self = this; replace( this._window, 'onpopstate', function (orig) { return function () { var current = self._location.href; self.handleUrlChange(self._lastHref, current); if (orig) { orig.apply(this, arguments); } }; }, this.replacements, 'navigation', ); replace( this._window.history, 'pushState', function (orig) { return function () { var url = arguments.length > 2 ? arguments[2] : undefined; if (url) { self.handleUrlChange(self._lastHref, url + ''); } return orig.apply(this, arguments); }; }, this.replacements, 'navigation', ); }; Instrumenter.prototype.handleUrlChange = function (from, to) { var parsedHref = urlparser.parse(this._location.href); var parsedTo = urlparser.parse(to); var parsedFrom = urlparser.parse(from); this._lastHref = to; if ( parsedHref.protocol === parsedTo.protocol && parsedHref.host === parsedTo.host ) { to = parsedTo.path + (parsedTo.hash || ''); } if ( parsedHref.protocol === parsedFrom.protocol && parsedHref.host === parsedFrom.host ) { from = parsedFrom.path + (parsedFrom.hash || ''); } this.telemeter.captureNavigation(from, to); }; Instrumenter.prototype.deinstrumentConnectivity = function () { if (!('addEventListener' in this._window || 'body' in this._document)) { return; } if (this._window.addEventListener) { this.removeListeners('connectivity'); } else { restore(this.replacements, 'connectivity'); } }; Instrumenter.prototype.instrumentConnectivity = function () { if (!('addEventListener' in this._window || 'body' in this._document)) { return; } if (this._window.addEventListener) { this.addListener( 'connectivity', this._window, 'online', undefined, function () { this.telemeter.captureConnectivityChange('online'); }.bind(this), true, ); this.addListener( 'connectivity', this._window, 'offline', undefined, function () { this.telemeter.captureConnectivityChange('offline'); }.bind(this), true, ); } else { var self = this; replace( this._document.body, 'ononline', function (orig) { return function () { self.telemeter.captureConnectivityChange('online'); if (orig) { orig.apply(this, arguments); } }; }, this.replacements, 'connectivity', ); replace( this._document.body, 'onoffline', function (orig) { return function () { self.telemeter.captureConnectivityChange('offline'); if (orig) { orig.apply(this, arguments); } }; }, this.replacements, 'connectivity', ); } }; Instrumenter.prototype.handleCspEvent = function (cspEvent) { var message = 'Security Policy Violation: ' + 'blockedURI: ' + cspEvent.blockedURI + ', ' + 'violatedDirective: ' + cspEvent.violatedDirective + ', ' + 'effectiveDirective: ' + cspEvent.effectiveDirective + ', '; if (cspEvent.sourceFile) { message += 'location: ' + cspEvent.sourceFile + ', ' + 'line: ' + cspEvent.lineNumber + ', ' + 'col: ' + cspEvent.columnNumber + ', '; } message += 'originalPolicy: ' + cspEvent.originalPolicy; this.telemeter.captureLog(message, 'error'); this.handleCspError(message); }; Instrumenter.prototype.handleCspError = function (message) { if (this.autoInstrument.errorOnContentSecurityPolicy) { this.rollbar.error(message); } }; Instrumenter.prototype.deinstrumentContentSecurityPolicy = function () { if (!('addEventListener' in this._document)) { return; } this.removeListeners('contentsecuritypolicy'); }; Instrumenter.prototype.instrumentContentSecurityPolicy = function () { if (!('addEventListener' in this._document)) { return; } var cspHandler = this.handleCspEvent.bind(this); this.addListener( 'contentsecuritypolicy', this._document, 'securitypolicyviolation', null, cspHandler, false, ); }; Instrumenter.prototype.addListener = function ( section, obj, type, altType, handler, capture, ) { if (obj.addEventListener) { obj.addEventListener(type, handler, capture); this.eventRemovers[section].push(function () { obj.removeEventListener(type, handler, capture); }); } else if (altType) { obj.attachEvent(altType, handler); this.eventRemovers[section].push(function () { obj.detachEvent(altType, handler); }); } }; Instrumenter.prototype.removeListeners = function (section) { var r; while (this.eventRemovers[section].length) { r = this.eventRemovers[section].shift(); r(); } }; function _isUrlObject(input) { return typeof URL !== 'undefined' && input instanceof URL; } module.exports = Instrumenter;