diff --git a/plugins/console.js b/plugins/console.js index 795e6d6b29ca..e1fac679ab3a 100644 --- a/plugins/console.js +++ b/plugins/console.js @@ -27,7 +27,14 @@ function consolePlugin(Raven, console, pluginOptions) { if (l === 'warn') l = 'warning'; return function () { var args = [].slice.call(arguments); - Raven.captureMessage('' + args.join(' '), {level: l, logger: 'console', extra: { 'arguments': args }}); + + var msg = '' + args.join(' '); + var data = {level: l, logger: 'console', extra: { 'arguments': args }}; + if (pluginOptions.callback) { + pluginOptions.callback(msg, data); + } else { + Raven.captureMessage(msg, data); + } // this fails for some browsers. :( if (originalConsoleLevel) { diff --git a/src/raven.js b/src/raven.js index e6c361432572..ae164dea9152 100644 --- a/src/raven.js +++ b/src/raven.js @@ -2,6 +2,7 @@ 'use strict'; var TraceKit = require('../vendor/TraceKit/tracekit'); +var consolePlugin = require('../plugins/console'); var RavenConfigError = require('./configError'); var utils = require('./utils'); @@ -16,6 +17,7 @@ var objectMerge = utils.objectMerge; var truncate = utils.truncate; var urlencode = utils.urlencode; var uuid4 = utils.uuid4; +var htmlElementAsString = utils.htmlElementAsString; var dsnKeys = 'source protocol user pass host port path'.split(' '), dsnPattern = /^(?:(\w+):)?\/\/(?:(\w+)(:\w+)?@)?([\w\.-]+)(?::(\d+))?(\/.*)/; @@ -58,6 +60,10 @@ function Raven() { this._plugins = []; this._startTime = now(); this._wrappedBuiltIns = []; + this._breadcrumbs = []; + this._breadcrumbLimit = 20; + this._lastCapturedEvent = null; + this._lastHref = window.location && location.href; for (var method in this._originalConsole) { // eslint-disable-line guard-for-in this._originalConsoleMethods[method] = this._originalConsole[method]; @@ -198,11 +204,11 @@ Raven.prototype = { * * @param {object} options A specific set of options for this context [optional] * @param {function} func The function to be wrapped in a new context + * @param {function} func A function to call before the try/catch wrapper [optional, private] * @return {function} The newly wrapped functions with a context */ - wrap: function(options, func) { + wrap: function(options, func, _before) { var self = this; - // 1 argument has been passed, and it's not a function // so just return it if (isUndefined(func) && !isFunction(options)) { @@ -241,9 +247,13 @@ Raven.prototype = { function wrapped() { var args = [], i = arguments.length, deep = !options || options && options.deep !== false; + + if (_before && isFunction(_before)) { + _before.apply(this, arguments); + } + // Recursively wrap all of a function's arguments that are // functions themselves. - while(i--) args[i] = deep ? self.wrap(options, arguments[i]) : arguments[i]; try { @@ -261,10 +271,9 @@ Raven.prototype = { wrapped[property] = func[property]; } } - func.__raven_wrapper__ = wrapped; - wrapped.prototype = func.prototype; + func.__raven_wrapper__ = wrapped; // Signal that this function has been wrapped already // for both debugging and to prevent it to being wrapped twice wrapped.__raven__ = true; @@ -345,6 +354,19 @@ Raven.prototype = { return this; }, + captureBreadcrumb: function (type, data) { + var crumb = { + type: type, + timestamp: now() / 1000, + data: data + }; + + this._breadcrumbs.push(crumb); + if (this._breadcrumbs.length > this._breadcrumbLimit) { + this._breadcrumbs.shift(); + } + }, + addPlugin: function(plugin /*arg1, arg2, ... argN*/) { var pluginArgs = Array.prototype.slice.call(arguments, 1); @@ -594,6 +616,32 @@ Raven.prototype = { } }, + + /** + * Wraps addEventListener to capture breadcrumbs + * @param evtName the event name (e.g. "click") + * @param fn the function being wrapped + * @returns {Function} + * @private + */ + _breadcrumbEventHandler: function(evtName) { + var self = this; + return function (evt) { + // It's possible this handler might trigger multiple times for the same + // event (e.g. event propagation through node ancestors). Ignore if we've + // already captured the event. + if (self._lastCapturedEvent === evt) + return; + + self._lastCapturedEvent = evt; + var elem = evt.target; + self.captureBreadcrumb('ui_event', { + type: evtName, + target: htmlElementAsString(elem) + }); + }; + }, + /** * Install any queued plugins */ @@ -638,6 +686,13 @@ Raven.prototype = { }); } + // Capture breadcrubms from any click that is unhandled / bubbled up all the way + // to the document. Do this before we instrument addEventListener. + if (this._hasDocument) { + document.addEventListener('click', self._breadcrumbEventHandler('click')); + + } + // event targets borrowed from bugsnag-js: // https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666 'EventTarget Window Node ApplicationCache AudioTrackList ChannelMergerNode CryptoOperation EventSource FileReader HTMLUnknownElement IDBDatabase IDBRequest IDBTransaction KeyOperation MediaController MessagePort ModalWindow Notification SVGElementInstance Screen TextTrack TextTrackCue TextTrackList WebSocket WebSocketWorker Worker XMLHttpRequest XMLHttpRequestEventTarget XMLHttpRequestUpload'.replace(/\w+/g, function (global) { @@ -652,7 +707,14 @@ Raven.prototype = { } catch (err) { // can sometimes get 'Permission denied to access property "handle Event' } - return orig.call(this, evt, self.wrap(fn), capture, secure); + + + // TODO: more than just click + var before; + if ((global === 'EventTarget' || global === 'Node') && evt === 'click') { + before = self._breadcrumbEventHandler(evt, fn); + } + return orig.call(this, evt, self.wrap(fn, undefined, before), capture, secure); }; }); fill(proto, 'removeEventListener', function (orig) { @@ -665,12 +727,32 @@ Raven.prototype = { }); if ('XMLHttpRequest' in window) { - fill(XMLHttpRequest.prototype, 'send', function(origSend) { + var xhrproto = XMLHttpRequest.prototype; + fill(xhrproto, 'open', function(origOpen) { + return function (method, url) { // preserve arity + this.__raven_xhr = { + method: method, + url: url, + statusCode: null + }; + return origOpen.apply(this, arguments); + }; + }); + + fill(xhrproto, 'send', function(origSend) { return function (data) { // preserve arity var xhr = this; 'onreadystatechange onload onerror onprogress'.replace(/\w+/g, function (prop) { - if (prop in xhr && Object.prototype.toString.call(xhr[prop]) === '[object Function]') { + if (prop in xhr && isFunction(xhr[prop])) { fill(xhr, prop, function (orig) { + if (prop === 'onreadystatechange' && xhr.__raven_xhr && (xhr.readyState === 1 || xhr.readyState === 4)) { + try { + // touching statusCode in some platforms throws + // an exception + xhr.__raven_xhr.statusCode = xhr.status; + } catch (e) { /* do nothing */ } + self.captureBreadcrumb('http_request', xhr.__raven_xhr); + } return self.wrap(orig); }, true /* noUndo */); // don't track filled methods on XHR instances } @@ -680,6 +762,53 @@ Raven.prototype = { }); } + // record navigation (URL) changes + if ('history' in window && history.pushState) { + // TODO: remove onpopstate handler on uninstall() + var oldOnPopState = window.onpopstate; + window.onpopstate = function () { + self.captureBreadcrumb('navigation', { + from: self._lastHref, + to: location.href + }); + + // because onpopstate only tells you the "new" (to) value of location.href, and + // not the previous (from) value, we need to track the value of location.href + // ourselves + self._lastHref = location.href; + if (oldOnPopState) { + return oldOnPopState.apply(this, arguments); + } + }; + + fill(history, 'pushState', function (origPushState) { + // note history.pushState.length is 0; intentionally not declaring + // params to preserve 0 arity + return function(/* state, title, url */) { + var url = arguments.length > 2 ? arguments[2] : undefined; + self.captureBreadcrumb('navigation', { + to: url, + from: location.href + }); + if (url) self._lastHref = url; + return origPushState.apply(this, arguments); + } + }); + } + + // console + if ('console' in window && console.log) { + consolePlugin(self, console, { + levels: ['debug', 'info', 'warn', 'error', 'log'], + callback: function (msg, data) { + self.captureBreadcrumb('message', { + level: data.level, + message: msg + }); + } + }); + } + var $ = window.jQuery || window.$; if ($ && $.fn && $.fn.ready) { fill($.fn, 'ready', function (orig) { @@ -952,6 +1081,12 @@ Raven.prototype = { // Send along our own collected metadata with extra data.extra['session:duration'] = now() - this._startTime; + if (this._breadcrumbs && this._breadcrumbs.length > 0) { + data.breadcrumbs = { + values: this._breadcrumbs + }; + } + // If there are no tags/extra, strip the key from the payload alltogther. if (isEmptyObject(data.tags)) delete data.tags; @@ -1001,6 +1136,11 @@ Raven.prototype = { auth.sentry_secret = this._globalSecret; } + this.captureBreadcrumb('sentry', { + message: data.message, + eventId: data.event_id + }); + var url = this._globalEndpoint; (globalOptions.transport || this._makeRequest).call(this, { url: url, diff --git a/src/utils.js b/src/utils.js index b790f84314b8..ae0c05800924 100644 --- a/src/utils.js +++ b/src/utils.js @@ -140,6 +140,25 @@ function uuid4() { } } +/** + * Returns a simple, child-less string representation of a DOM element + * e.g. [HTMLElement] => + * @param HTMLElement + */ +function htmlElementAsString(elem) { + var out = ['<']; + out.push(elem.tagName.toLowerCase()); + var attrWhitelist = ['id', 'type', 'name', 'value', 'class', 'placeholder', 'title', 'alt']; + each(attrWhitelist, function(index, key) { + var attr = elem.getAttribute(key); + if (attr) { + out.push(' ' + key + '="' + attr + '"'); + } + }); + out.push(' />'); + return out.join(''); +} + module.exports = { isUndefined: isUndefined, isFunction: isFunction, @@ -153,5 +172,6 @@ module.exports = { hasKey: hasKey, joinRegExp: joinRegExp, urlencode: urlencode, - uuid4: uuid4 + uuid4: uuid4, + htmlElementAsString: htmlElementAsString }; diff --git a/test/integration/frame.html b/test/integration/frame.html index ae2d6603e164..fe43cf7f4507 100644 --- a/test/integration/frame.html +++ b/test/integration/frame.html @@ -73,5 +73,13 @@ + + + +
+
+
+
+
diff --git a/test/integration/test.js b/test/integration/test.js index 82bd0c5cb48c..62de4ed4f68d 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -23,6 +23,17 @@ function createIframe(done) { return iframe; } +var anchor = document.createElement('a'); +function parseUrl(url) { + var out = {pathname: '', origin: '', protocol: ''}; + if (!url) + anchor.href = url; + for (var key in out) { + out[key] = anchor[key]; + } + return out; +} + describe('integration', function () { beforeEach(function (done) { @@ -288,6 +299,186 @@ describe('integration', function () { }); }); + describe('breadcrumbs', function () { + it('should record a mouse click on element WITH click handler present', function (done) { + var iframe = this.iframe; + + iframeExecute(iframe, done, + function () { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + Raven._breadcrumbs = []; + + // add an event listener to the input. we want to make sure that + // our breadcrumbs still work even if the page has an event listener + // on an element that cancels event bubbling + var input = document.getElementsByTagName('input')[0]; + var clickHandler = function (evt) { + evt.stopPropagation(); // don't bubble + }; + input.addEventListener('click', clickHandler); + + // click + var evt = document.createEvent('MouseEvent'); + evt.initMouseEvent( + "click", + true /* bubble */, + true /* cancelable */, + window, + null, + 0, 0, 0, 0, /* coordinates */ + false, false, false, false, /* modifier keys */ + 0 /*left*/, + null + ); + input.dispatchEvent(evt); + }, + function () { + var Raven = iframe.contentWindow.Raven, + breadcrumbs = Raven._breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].type, 'ui_event'); + // NOTE: attributes re-ordered. should this be expected? + assert.equal(breadcrumbs[0].data.target, ''); + assert.equal(breadcrumbs[0].data.type, 'click'); + } + ); + }); + + it('should record a mouse click on element WITHOUT click handler present', function (done) { + var iframe = this.iframe; + + iframeExecute(iframe, done, + function () { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + Raven._breadcrumbs = []; + + // click + var evt = document.createEvent('MouseEvent'); + evt.initMouseEvent( + "click", + true /* bubble */, + true /* cancelable */, + window, + null, + 0, 0, 0, 0, /* coordinates */ + false, false, false, false, /* modifier keys */ + 0 /*left*/, + null + ); + + var input = document.getElementsByTagName('input')[0]; + input.dispatchEvent(evt); + }, + function () { + var Raven = iframe.contentWindow.Raven, + breadcrumbs = Raven._breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].type, 'ui_event'); + // NOTE: attributes re-ordered. should this be expected? + assert.equal(breadcrumbs[0].data.target, ''); + assert.equal(breadcrumbs[0].data.type, 'click'); + } + ); + }); + + it('should only record a SINGLE mouse click for a tree of elements with event listeners', function (done) { + var iframe = this.iframe; + + iframeExecute(iframe, done, + function () { + setTimeout(done); + + // some browsers trigger onpopstate for load / reset breadcrumb state + Raven._breadcrumbs = []; + + var clickHandler = function (evt) { + //evt.stopPropagation(); + }; + document.getElementById('a').addEventListener('click', clickHandler); + document.getElementById('b').addEventListener('click', clickHandler); + document.getElementById('c').addEventListener('click', clickHandler); + + // click + var evt = document.createEvent('MouseEvent'); + evt.initMouseEvent( + "click", + true /* bubble */, + true /* cancelable */, + window, + null, + 0, 0, 0, 0, /* coordinates */ + false, false, false, false, /* modifier keys */ + 0 /*left*/, + null + ); + + var input = document.getElementById('a'); // leaf node + input.dispatchEvent(evt); + }, + function () { + var Raven = iframe.contentWindow.Raven, + breadcrumbs = Raven._breadcrumbs; + + assert.equal(breadcrumbs.length, 1); + + assert.equal(breadcrumbs[0].type, 'ui_event'); + // NOTE: attributes re-ordered. should this be expected? + assert.equal(breadcrumbs[0].data.target, '
'); + assert.equal(breadcrumbs[0].data.type, 'click'); + } + ); + }); + + it('should record history.[pushState|back] changes as navigation breadcrumbs', function (done) { + var iframe = this.iframe; + + iframeExecute(iframe, done, + function () { + // some browsers trigger onpopstate for load / reset breadcrumb state + Raven._breadcrumbs = []; + history.pushState({}, '', '/foo'); + history.pushState({}, '', '/bar'); + + // can't call history.back() because it will change url of parent document + // (e.g. document running mocha) ... instead just "emulate" a back button + // press by calling replaceState + onpopstate manually + history.replaceState({}, '', '/foo'); + window.onpopstate(); + done(); + }, + function () { + var Raven = iframe.contentWindow.Raven, + breadcrumbs = Raven._breadcrumbs, + from, + to; + + assert.equal(breadcrumbs.length, 3); + assert.equal(breadcrumbs[0].type, 'navigation'); // (start) => foo + assert.equal(breadcrumbs[1].type, 'navigation'); // foo => bar + assert.equal(breadcrumbs[2].type, 'navigation'); // bar => foo (back button) + + // assert end of string because PhantomJS uses full system path + assert.ok(/\/test\/integration\/frame\.html$/.test(Raven._breadcrumbs[0].data.from), '\'from\' url is incorrect'); + assert.ok(/\/foo$/.test(breadcrumbs[0].data.to), '\'to\' url is incorrect'); + + assert.ok(/\/foo$/.test(breadcrumbs[1].data.from), '\'from\' url is incorrect'); + assert.ok(/\/bar$/.test(breadcrumbs[1].data.to), '\'to\' url is incorrect'); + + assert.ok(/\/bar/.test(breadcrumbs[2].data.from), '\'from\' url is incorrect'); + assert.ok(/\/foo/.test(breadcrumbs[2].data.to), '\'to\' url is incorrect'); + } + ); + }); + }); + describe('uninstall', function () { it('should restore original built-ins', function (done) { var iframe = this.iframe; diff --git a/test/raven.test.js b/test/raven.test.js index a2c7f50fc2c6..0876b49a5438 100644 --- a/test/raven.test.js +++ b/test/raven.test.js @@ -718,6 +718,7 @@ describe('globals', function() { logger: 'javascript', maxMessageLength: 100 }; + Raven._breadcrumbs = [{type: 'request', timestamp: 0.1, data: {method: 'POST', url: 'http://example.org/api/0/auth/'}}]; Raven._send({message: 'bar'}); assert.deepEqual(Raven._makeRequest.lastCall.args[0].data, { @@ -732,7 +733,13 @@ describe('globals', function() { }, event_id: 'abc123', message: 'bar', - extra: {'session:duration': 100} + extra: {'session:duration': 100}, + breadcrumbs: { + values: [ + { type: 'request', timestamp: 0.1, data: { method: 'POST', url: 'http://example.org/api/0/auth/' }}, + { type: 'sentry', timestamp: 0.1, /* 100ms */ data: { message: 'bar', eventId: 'abc123' }} + ] + } }); }); @@ -800,7 +807,7 @@ describe('globals', function() { event_id: 'abc123', message: 'bar', tags: {tag1: 'value1', tag2: 'value2'}, - extra: {'session:duration': 100} + extra: {'session:duration': 100}, }); @@ -842,7 +849,7 @@ describe('globals', function() { event_id: 'abc123', message: 'bar', - extra: {key1: 'value1', key2: 'value2', 'session:duration': 100} + extra: {key1: 'value1', key2: 'value2', 'session:duration': 100}, }); assert.deepEqual(Raven._globalOptions, { @@ -1530,7 +1537,15 @@ describe('globals', function() { describe('Raven (public API)', function() { beforeEach(function () { + this.clock = sinon.useFakeTimers(); + this.clock.tick(0); // Raven initialized at time "0" Raven = new _Raven(); + + this.clock.tick(100); // tick 100 ms + }); + + afterEach(function () { + this.clock.restore(); }); describe('.VERSION', function() { @@ -2144,6 +2159,44 @@ describe('Raven (public API)', function() { }); }); + describe('.captureBreadcrumb', function () { + it('should store the passed object in _breadcrumbs', function() { + Raven.captureBreadcrumb('http_request', { + url: 'http://example.org/api/0/auth/', + statusCode: 200 + }); + + assert.deepEqual(Raven._breadcrumbs[0], { + type: 'http_request', + timestamp: 0.1, + data: { + url: 'http://example.org/api/0/auth/', + statusCode: 200 + } + }); + }); + + it('should dequeue the oldest breadcrumb when over limit', function() { + Raven._breadcrumbLimit = 5; + Raven._breadcrumbs = [ + { type: 'message', timestamp: 0.1, data: { message: '1' }}, + { type: 'message', timestamp: 0.1, data: { message: '2' }}, + { type: 'message', timestamp: 0.1, data: { message: '3' }}, + { type: 'message', timestamp: 0.1, data: { message: '4' }}, + { type: 'message', timestamp: 0.1, data: { message: '5' }} + ]; + + Raven.captureBreadcrumb('message', { message: 'lol' }); + assert.deepEqual(Raven._breadcrumbs, [ + { type: 'message', timestamp: 0.1, data: { message: '2' }}, + { type: 'message', timestamp: 0.1, data: { message: '3' }}, + { type: 'message', timestamp: 0.1, data: { message: '4' }}, + { type: 'message', timestamp: 0.1, data: { message: '5' }}, + { type: 'message', timestamp: 0.1, data: { message: 'lol' }} + ]); + }); + }); + describe('.Raven.isSetup', function() { it('should work as advertised', function() { var isSetup = this.sinon.stub(Raven, 'isSetup'); diff --git a/test/utils.test.js b/test/utils.test.js index bcaac82e3214..79fc8cc22ead 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -16,6 +16,7 @@ var joinRegExp = utils.joinRegExp; var objectMerge = utils.objectMerge; var truncate = utils.truncate; var urlencode = utils.urlencode; +var htmlElementAsString = utils.htmlElementAsString; describe('utils', function () { describe('isUndefined', function() { @@ -118,4 +119,33 @@ describe('utils', function () { assert.equal(urlencode({'foo': 'bar', 'baz': '1 2'}), 'foo=bar&baz=1%202'); }); }); + + describe('htmlElementAsString', function () { + it('should work', function () { + assert.equal(htmlElementAsString({ + tagName: 'INPUT', + getAttribute: function (key){ + return { + id: 'the-username', + name: 'username', + class: 'form-control', + placeholder: 'Enter your username' + }[key]; + } + }), ''); + + assert.equal(htmlElementAsString({ + tagName: 'IMG', + getAttribute: function (key){ + return { + id: 'image-3', + title: 'A picture of an apple', + 'data-something': 'This should be ignored' // skipping data-* attributes in first implementation + }[key]; + } + }), ''); + }); + + it + }); });