From 3cc58e8cbb0d8ffee560563f8f61871be6aedcbf Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 24 Feb 2016 12:03:02 -0800 Subject: [PATCH 01/11] Capture breadcrumbs from XHRs and transfer in _send --- src/raven.js | 40 +++++++++++++++++++++++++++++++++-- test/raven.test.js | 52 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/raven.js b/src/raven.js index e6c361432572..e01d768808a1 100644 --- a/src/raven.js +++ b/src/raven.js @@ -58,6 +58,8 @@ function Raven() { this._plugins = []; this._startTime = now(); this._wrappedBuiltIns = []; + this._breadcrumbs = []; + this._breadcrumbLimit = 20; for (var method in this._originalConsole) { // eslint-disable-line guard-for-in this._originalConsoleMethods[method] = this._originalConsole[method]; @@ -345,6 +347,15 @@ Raven.prototype = { return this; }, + captureBreadcrumb: function (obj) { + obj.timestamp = now(); + + this._breadcrumbs.push(obj); + if (this._breadcrumbs.length > this._breadcrumbLimit) { + this._breadcrumbs.shift(); + } + }, + addPlugin: function(plugin /*arg1, arg2, ... argN*/) { var pluginArgs = Array.prototype.slice.call(arguments, 1); @@ -665,12 +676,35 @@ 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({ + type: 'request', + data: xhr.__raven_xhr + }); + } return self.wrap(orig); }, true /* noUndo */); // don't track filled methods on XHR instances } @@ -952,6 +986,8 @@ Raven.prototype = { // Send along our own collected metadata with extra data.extra['session:duration'] = now() - this._startTime; + data.breadcrumbs = this._breadcrumbs; + // If there are no tags/extra, strip the key from the payload alltogther. if (isEmptyObject(data.tags)) delete data.tags; diff --git a/test/raven.test.js b/test/raven.test.js index a2c7f50fc2c6..0672e663cc6e 100644 --- a/test/raven.test.js +++ b/test/raven.test.js @@ -718,6 +718,9 @@ describe('globals', function() { logger: 'javascript', maxMessageLength: 100 }; + Raven._breadcrumbs = [ + { type: 'request', data: { method: 'POST', url: 'http://example.org/api/0/auth/' }} + ]; Raven._send({message: 'bar'}); assert.deepEqual(Raven._makeRequest.lastCall.args[0].data, { @@ -732,7 +735,8 @@ describe('globals', function() { }, event_id: 'abc123', message: 'bar', - extra: {'session:duration': 100} + extra: {'session:duration': 100}, + breadcrumbs: [{ type: 'request', data: { method: 'POST', url: 'http://example.org/api/0/auth/' }}] }); }); @@ -767,7 +771,8 @@ describe('globals', function() { name: 'Matt' }, message: 'bar', - extra: {'session:duration': 100} + extra: {'session:duration': 100}, + breadcrumbs: [] }); }); @@ -800,7 +805,8 @@ describe('globals', function() { event_id: 'abc123', message: 'bar', tags: {tag1: 'value1', tag2: 'value2'}, - extra: {'session:duration': 100} + extra: {'session:duration': 100}, + breadcrumbs: [] }); @@ -842,7 +848,8 @@ describe('globals', function() { event_id: 'abc123', message: 'bar', - extra: {key1: 'value1', key2: 'value2', 'session:duration': 100} + extra: {key1: 'value1', key2: 'value2', 'session:duration': 100}, + breadcrumbs: [] }); assert.deepEqual(Raven._globalOptions, { @@ -2144,6 +2151,43 @@ describe('Raven (public API)', function() { }); }); + describe('.captureBreadcrumb', function () { + it('should store the passed object in _breadcrumbs', function() { + var breadcrumb = { + type: 'request', + timestamp: 100, + data: { + url: 'http://example.org/api/0/auth/', + statusCode: 200 + } + }; + + Raven.captureBreadcrumb(breadcrumb); + + assert.equal(Raven._breadcrumbs[0], breadcrumb); + }); + + it('should dequeue the oldest breadcrumb when over limit', function() { + Raven._breadcrumbLimit = 5; + Raven._breadcrumbs = [ + { id: 1 }, + { id: 2 }, + { id: 3 }, + { id: 4 }, + { id: 5 } + ]; + + Raven.captureBreadcrumb({ id: 6 }); + assert.deepEqual(Raven._breadcrumbs, [ + { id: 2 }, + { id: 3 }, + { id: 4 }, + { id: 5 }, + { id: 6 } + ]); + }); + }); + describe('.Raven.isSetup', function() { it('should work as advertised', function() { var isSetup = this.sinon.stub(Raven, 'isSetup'); From ce86e8177fb8cfd0d4fdc84567d149cd7ba4fca6 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 23 Mar 2016 17:04:43 -0700 Subject: [PATCH 02/11] Collect breadcrumbs for navigation (url) changes --- src/raven.js | 36 +++++++++++++++++++++++++++-- test/integration/test.js | 50 ++++++++++++++++++++++++++++++++++++++++ test/raven.test.js | 9 +++----- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/raven.js b/src/raven.js index e01d768808a1..4431e1902ecb 100644 --- a/src/raven.js +++ b/src/raven.js @@ -348,7 +348,7 @@ Raven.prototype = { }, captureBreadcrumb: function (obj) { - obj.timestamp = now(); + obj.timestamp = obj.timestamp || now(); this._breadcrumbs.push(obj); if (this._breadcrumbs.length > this._breadcrumbLimit) { @@ -714,6 +714,38 @@ Raven.prototype = { }); } + // record navigation (URL) changes + if ('history' in window && history.pushState) { + var oldOnPopState = window.onpopstate; + window.onpopstate = function () { + self.captureBreadcrumb({ + type: 'navigation', + data: { + from: 'TODO', + to: 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 */) { + self.captureBreadcrumb({ + type: 'navigation', + data: { + to: arguments.length > 2 ? arguments[2] : '', + from: location.href + } + }); + return origPushState.apply(this, arguments); + } + }); + } + var $ = window.jQuery || window.$; if ($ && $.fn && $.fn.ready) { fill($.fn, 'ready', function (orig) { @@ -986,7 +1018,7 @@ Raven.prototype = { // Send along our own collected metadata with extra data.extra['session:duration'] = now() - this._startTime; - data.breadcrumbs = this._breadcrumbs; + if (this._breadcrumbs && this._breadcrumbs.length > 0) data.breadcrumbs = this._breadcrumbs; // If there are no tags/extra, strip the key from the payload alltogther. if (isEmptyObject(data.tags)) delete data.tags; diff --git a/test/integration/test.js b/test/integration/test.js index 82bd0c5cb48c..e99600eddffc 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) { @@ -286,6 +297,45 @@ describe('integration', function () { } ); }); + + it('should record pushState 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 call onpopstate directly + 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 () { diff --git a/test/raven.test.js b/test/raven.test.js index 0672e663cc6e..8bbf41cddc0f 100644 --- a/test/raven.test.js +++ b/test/raven.test.js @@ -771,8 +771,7 @@ describe('globals', function() { name: 'Matt' }, message: 'bar', - extra: {'session:duration': 100}, - breadcrumbs: [] + extra: {'session:duration': 100} }); }); @@ -806,7 +805,6 @@ describe('globals', function() { message: 'bar', tags: {tag1: 'value1', tag2: 'value2'}, extra: {'session:duration': 100}, - breadcrumbs: [] }); @@ -849,7 +847,6 @@ describe('globals', function() { event_id: 'abc123', message: 'bar', extra: {key1: 'value1', key2: 'value2', 'session:duration': 100}, - breadcrumbs: [] }); assert.deepEqual(Raven._globalOptions, { @@ -2177,13 +2174,13 @@ describe('Raven (public API)', function() { { id: 5 } ]; - Raven.captureBreadcrumb({ id: 6 }); + Raven.captureBreadcrumb({ id: 6, timestamp: 100 }); assert.deepEqual(Raven._breadcrumbs, [ { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, - { id: 6 } + { id: 6, timestamp: 100 } ]); }); }); From 5097dc1df9a139e1c297a8976e885930f6cee097 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 24 Mar 2016 13:41:56 -0700 Subject: [PATCH 03/11] Track current location.href for onpopstate events --- src/raven.js | 12 ++++++++++-- test/integration/test.js | 8 +++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/raven.js b/src/raven.js index 4431e1902ecb..14bfa6fd4786 100644 --- a/src/raven.js +++ b/src/raven.js @@ -60,6 +60,7 @@ function Raven() { this._wrappedBuiltIns = []; this._breadcrumbs = []; this._breadcrumbLimit = 20; + this._lastHref = window.location && location.href; for (var method in this._originalConsole) { // eslint-disable-line guard-for-in this._originalConsoleMethods[method] = this._originalConsole[method]; @@ -721,10 +722,15 @@ Raven.prototype = { self.captureBreadcrumb({ type: 'navigation', data: { - from: 'TODO', + 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); } @@ -734,13 +740,15 @@ Raven.prototype = { // 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({ type: 'navigation', data: { - to: arguments.length > 2 ? arguments[2] : '', + to: url, from: location.href } }); + if (url) self._lastHref = url; return origPushState.apply(this, arguments); } }); diff --git a/test/integration/test.js b/test/integration/test.js index e99600eddffc..63e1691ea1bc 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -309,7 +309,9 @@ describe('integration', function () { history.pushState({}, '', '/bar'); // can't call history.back() because it will change url of parent document - // (e.g. document running mocha) ... instead just call onpopstate directly + // (e.g. document running mocha) ... instead just "emulate" a back button + // press by calling replaceState + onpopstate manually + history.replaceState({}, '', '/foo'); window.onpopstate(); done(); }, @@ -331,8 +333,8 @@ describe('integration', function () { 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'); + assert.ok(/\/bar/.test(breadcrumbs[2].data.from), '\'from\' url is incorrect'); + assert.ok(/\/foo/.test(breadcrumbs[2].data.to), '\'to\' url is incorrect'); } ); }); From 42c6efc7278979678039d3ad34e57372ea01db89 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 24 Mar 2016 16:51:21 -0700 Subject: [PATCH 04/11] Add breadcrumbs for "click" ui events --- src/raven.js | 41 +++++++++++++++++++++++++++++++++++++--- test/integration/test.js | 2 +- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/raven.js b/src/raven.js index 14bfa6fd4786..5b717ca51cdb 100644 --- a/src/raven.js +++ b/src/raven.js @@ -349,7 +349,7 @@ Raven.prototype = { }, captureBreadcrumb: function (obj) { - obj.timestamp = obj.timestamp || now(); + obj.timestamp = (obj.timestamp || now()) / 1000; this._breadcrumbs.push(obj); if (this._breadcrumbs.length > this._breadcrumbLimit) { @@ -606,6 +606,30 @@ Raven.prototype = { } }, + + /** + * Wraps addEventListener to capture breadcrumbs + * @param elem the element addEventListener was called on + * @param evt the event name (e.g. "click") + * @param fn the function being wrapped + * @param origArgs the original arguments to addEventListener + * @returns {Function} + * @private + */ + _wrapEventHandlerForBreadcrumbs: function(elem, evt, fn, origArgs) { + var self = this; + return function () { + self.captureBreadcrumb({ + type: "ui_event", + data: { + type: evt, + target: elem.outerHTML + } + }); + return fn.apply(this, origArgs); + } + }, + /** * Install any queued plugins */ @@ -657,6 +681,7 @@ Raven.prototype = { if (proto && proto.hasOwnProperty && proto.hasOwnProperty('addEventListener')) { fill(proto, 'addEventListener', function(orig) { return function (evt, fn, capture, secure) { // preserve arity + var args = [].slice.apply(arguments); try { if (fn && fn.handleEvent) { fn.handleEvent = self.wrap(fn.handleEvent); @@ -664,6 +689,11 @@ Raven.prototype = { } catch (err) { // can sometimes get 'Permission denied to access property "handle Event' } + + // TODO: more than just click + if (global === 'EventTarget' && evt === 'click') { + fn = self._wrapEventHandlerForBreadcrumbs(this, evt, fn, args); + } return orig.call(this, evt, self.wrap(fn), capture, secure); }; }); @@ -717,6 +747,7 @@ 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({ @@ -1014,7 +1045,7 @@ Raven.prototype = { }, httpData = this._getHttpData(); if (httpData) { - baseData.request = httpData; + baseData.http_request = httpData; } data = objectMerge(baseData, data); @@ -1026,7 +1057,11 @@ 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 = this._breadcrumbs; + if (this._breadcrumbs && this._breadcrumbs.length > 0) { + data.breadcrumbs = { + items: this._breadcrumbs + }; + } // If there are no tags/extra, strip the key from the payload alltogther. if (isEmptyObject(data.tags)) delete data.tags; diff --git a/test/integration/test.js b/test/integration/test.js index 63e1691ea1bc..06a9eca5c2a7 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -298,7 +298,7 @@ describe('integration', function () { ); }); - it('should record pushState changes as navigation breadcrumbs', function (done) { + it('should record history.[pushState|back] changes as navigation breadcrumbs', function (done) { var iframe = this.iframe; iframeExecute(iframe, done, From 0e26bfa34a493ddfd79bb4cf28baeb928b045e02 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 24 Mar 2016 18:24:33 -0700 Subject: [PATCH 05/11] Add console logging to breadcrumbs by leveraging console plugin --- plugins/console.js | 9 ++++++++- src/raven.js | 23 ++++++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) 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 5b717ca51cdb..9a69a9df0cf9 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'); @@ -732,7 +733,7 @@ Raven.prototype = { xhr.__raven_xhr.statusCode = xhr.status; } catch (e) { /* do nothing */ } self.captureBreadcrumb({ - type: 'request', + type: 'http_request', data: xhr.__raven_xhr }); } @@ -785,6 +786,22 @@ Raven.prototype = { }); } + // console + if ('console' in window && console.log) { + consolePlugin(self, console, { + levels: ['debug', 'info', 'warn', 'error', 'log'], + callback: function (msg, data) { + self.captureBreadcrumb({ + type: 'message', + data: { + level: data.level, + message: msg + } + }) + } + }); + } + var $ = window.jQuery || window.$; if ($ && $.fn && $.fn.ready) { fill($.fn, 'ready', function (orig) { @@ -1045,7 +1062,7 @@ Raven.prototype = { }, httpData = this._getHttpData(); if (httpData) { - baseData.http_request = httpData; + baseData.request = httpData; } data = objectMerge(baseData, data); @@ -1059,7 +1076,7 @@ Raven.prototype = { if (this._breadcrumbs && this._breadcrumbs.length > 0) { data.breadcrumbs = { - items: this._breadcrumbs + values: this._breadcrumbs }; } From 5ec0aaa0330fe323ae4ed1892d12931599e0e7e5 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 25 Mar 2016 17:26:22 -0700 Subject: [PATCH 06/11] Fix bad click event wrapper --- src/raven.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/raven.js b/src/raven.js index 9a69a9df0cf9..2e5082feac81 100644 --- a/src/raven.js +++ b/src/raven.js @@ -613,11 +613,10 @@ Raven.prototype = { * @param elem the element addEventListener was called on * @param evt the event name (e.g. "click") * @param fn the function being wrapped - * @param origArgs the original arguments to addEventListener * @returns {Function} * @private */ - _wrapEventHandlerForBreadcrumbs: function(elem, evt, fn, origArgs) { + _wrapEventHandlerForBreadcrumbs: function(elem, evt, fn) { var self = this; return function () { self.captureBreadcrumb({ @@ -627,7 +626,7 @@ Raven.prototype = { target: elem.outerHTML } }); - return fn.apply(this, origArgs); + return fn.apply(this, arguments); } }, @@ -682,7 +681,6 @@ Raven.prototype = { if (proto && proto.hasOwnProperty && proto.hasOwnProperty('addEventListener')) { fill(proto, 'addEventListener', function(orig) { return function (evt, fn, capture, secure) { // preserve arity - var args = [].slice.apply(arguments); try { if (fn && fn.handleEvent) { fn.handleEvent = self.wrap(fn.handleEvent); @@ -693,7 +691,7 @@ Raven.prototype = { // TODO: more than just click if (global === 'EventTarget' && evt === 'click') { - fn = self._wrapEventHandlerForBreadcrumbs(this, evt, fn, args); + fn = self._wrapEventHandlerForBreadcrumbs(this, evt, fn); } return orig.call(this, evt, self.wrap(fn), capture, secure); }; From f1c6c38e6707216b69463d38e2cafc10b6dad5e5 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 29 Mar 2016 16:31:12 -0700 Subject: [PATCH 07/11] Capture "sentry" breadcrumb (previous msgs, errors) --- src/raven.js | 10 +++++++++- test/raven.test.js | 13 ++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/raven.js b/src/raven.js index 2e5082feac81..9f2cb00d70b4 100644 --- a/src/raven.js +++ b/src/raven.js @@ -620,7 +620,7 @@ Raven.prototype = { var self = this; return function () { self.captureBreadcrumb({ - type: "ui_event", + type: 'ui_event', data: { type: evt, target: elem.outerHTML @@ -1127,6 +1127,14 @@ Raven.prototype = { auth.sentry_secret = this._globalSecret; } + this.captureBreadcrumb({ + type: 'sentry', + data: { + message: data.message, + eventId: data.event_id + } + }); + var url = this._globalEndpoint; (globalOptions.transport || this._makeRequest).call(this, { url: url, diff --git a/test/raven.test.js b/test/raven.test.js index 8bbf41cddc0f..84bd9858fcf0 100644 --- a/test/raven.test.js +++ b/test/raven.test.js @@ -718,9 +718,7 @@ describe('globals', function() { logger: 'javascript', maxMessageLength: 100 }; - Raven._breadcrumbs = [ - { type: 'request', data: { method: 'POST', url: 'http://example.org/api/0/auth/' }} - ]; + 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, { @@ -736,7 +734,12 @@ describe('globals', function() { event_id: 'abc123', message: 'bar', extra: {'session:duration': 100}, - breadcrumbs: [{ type: 'request', data: { method: 'POST', url: 'http://example.org/api/0/auth/' }}] + 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' }} + ] + } }); }); @@ -2180,7 +2183,7 @@ describe('Raven (public API)', function() { { id: 3 }, { id: 4 }, { id: 5 }, - { id: 6, timestamp: 100 } + { id: 6, timestamp: 0.1 } ]); }); }); From 132898e9634671f1e9a337a77ccf97539a5e290b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 29 Mar 2016 17:39:28 -0700 Subject: [PATCH 08/11] Implement naive html element prettify function for breadcrumbs --- src/raven.js | 3 ++- src/utils.js | 22 +++++++++++++++++++++- test/utils.test.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/raven.js b/src/raven.js index 9f2cb00d70b4..19da3f5ae155 100644 --- a/src/raven.js +++ b/src/raven.js @@ -17,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+))?(\/.*)/; @@ -623,7 +624,7 @@ Raven.prototype = { type: 'ui_event', data: { type: evt, - target: elem.outerHTML + target: htmlElementAsString(elem) } }); return fn.apply(this, arguments); 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/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 + }); }); From c3e847cf167c27799cee69b1684c5e3c406a5b4b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 30 Mar 2016 17:48:49 -0700 Subject: [PATCH 09/11] Tests, fixes for mouse click breadcrumb capturing --- src/raven.js | 68 +++++++++++++----- test/integration/frame.html | 8 +++ test/integration/test.js | 140 ++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 17 deletions(-) diff --git a/src/raven.js b/src/raven.js index 19da3f5ae155..531028fb337f 100644 --- a/src/raven.js +++ b/src/raven.js @@ -62,6 +62,7 @@ function Raven() { 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 @@ -207,7 +208,6 @@ Raven.prototype = { */ wrap: function(options, func) { var self = this; - // 1 argument has been passed, and it's not a function // so just return it if (isUndefined(func) && !isFunction(options)) { @@ -260,21 +260,29 @@ Raven.prototype = { } } + this._imitate(wrapped, func); + + 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; + wrapped.__inner__ = func; + + return wrapped; + }, + + /** + * Give a wrapper function the properties/prototype + * of the wrapepd (inner) function + */ + _imitate: function (wrapped, func) { // copy over properties of the old function for (var property in func) { if (hasKey(func, property)) { wrapped[property] = func[property]; } } - func.__raven_wrapper__ = wrapped; - wrapped.prototype = func.prototype; - - // Signal that this function has been wrapped already - // for both debugging and to prevent it to being wrapped twice - wrapped.__raven__ = true; - wrapped.__inner__ = func; - return wrapped; }, @@ -611,24 +619,39 @@ Raven.prototype = { /** * Wraps addEventListener to capture breadcrumbs - * @param elem the element addEventListener was called on - * @param evt the event name (e.g. "click") + * @param evtName the event name (e.g. "click") * @param fn the function being wrapped * @returns {Function} * @private */ - _wrapEventHandlerForBreadcrumbs: function(elem, evt, fn) { + _wrapEventHandlerForBreadcrumbs: function(evtName, fn) { var self = this; - return function () { + function wrapped(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({ type: 'ui_event', data: { - type: evt, + type: evtName, target: htmlElementAsString(elem) } }); - return fn.apply(this, arguments); + if (fn) return fn.apply(this, arguments); + }; + + if (fn) { + this._imitate(wrapped, fn); + fn.__raven_breadcrumb__ = wrapped; + } + + return wrapped; }, /** @@ -675,6 +698,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._wrapEventHandlerForBreadcrumbs('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) { @@ -691,14 +721,18 @@ Raven.prototype = { } // TODO: more than just click - if (global === 'EventTarget' && evt === 'click') { - fn = self._wrapEventHandlerForBreadcrumbs(this, evt, fn); + if ((global === 'EventTarget' || global === 'Node') && evt === 'click') { + fn = self._wrapEventHandlerForBreadcrumbs(evt, fn); } return orig.call(this, evt, self.wrap(fn), capture, secure); }; }); fill(proto, 'removeEventListener', function (orig) { return function (evt, fn, capture, secure) { + // from the original function, get the breadcrumb wrapper + // from the breadcrumb wrapper, get the raven wrapper (try/catch) + // i.e. fn => breadcrumb_wrapper(fn) => raven_wrapper(breadcrumb_wrapper(fn)) + fn = fn && (fn.__raven_breadcrumb__ ? fn.__raven_breadcrumb__ : fn); fn = fn && (fn.__raven_wrapper__ ? fn.__raven_wrapper__ : fn); return orig.call(this, evt, fn, capture, secure); }; 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 06a9eca5c2a7..97fa8bb3752c 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -173,6 +173,7 @@ describe('integration', function () { iframeExecute(iframe, done, function () { setTimeout(done); + debugger; var div = document.createElement('div'); document.body.appendChild(div); @@ -297,6 +298,145 @@ 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; From 99cf1472824399dfa93beb601f93c5f8b756a859 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 1 Apr 2016 13:06:04 -0700 Subject: [PATCH 10/11] Breadcrumb capturing should not end up in stack trace --- src/raven.js | 57 +++++++++++++++++++--------------------------------- 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/src/raven.js b/src/raven.js index 531028fb337f..e26520031552 100644 --- a/src/raven.js +++ b/src/raven.js @@ -204,9 +204,10 @@ 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 @@ -246,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 { @@ -260,22 +265,6 @@ Raven.prototype = { } } - this._imitate(wrapped, func); - - 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; - wrapped.__inner__ = func; - - return wrapped; - }, - - /** - * Give a wrapper function the properties/prototype - * of the wrapepd (inner) function - */ - _imitate: function (wrapped, func) { // copy over properties of the old function for (var property in func) { if (hasKey(func, property)) { @@ -283,6 +272,13 @@ Raven.prototype = { } } 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; + wrapped.__inner__ = func; + return wrapped; }, @@ -624,9 +620,9 @@ Raven.prototype = { * @returns {Function} * @private */ - _wrapEventHandlerForBreadcrumbs: function(evtName, fn) { + _breadcrumbEventHandler: function(evtName) { var self = this; - function wrapped(evt) { + 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. @@ -642,16 +638,7 @@ Raven.prototype = { target: htmlElementAsString(elem) } }); - if (fn) return fn.apply(this, arguments); }; - - if (fn) { - this._imitate(wrapped, fn); - fn.__raven_breadcrumb__ = wrapped; - - } - - return wrapped; }, /** @@ -701,7 +688,7 @@ 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._wrapEventHandlerForBreadcrumbs('click')); + document.addEventListener('click', self._breadcrumbEventHandler('click')); } @@ -720,19 +707,17 @@ Raven.prototype = { // can sometimes get 'Permission denied to access property "handle Event' } + // TODO: more than just click + var before; if ((global === 'EventTarget' || global === 'Node') && evt === 'click') { - fn = self._wrapEventHandlerForBreadcrumbs(evt, fn); + before = self._breadcrumbEventHandler(evt, fn); } - return orig.call(this, evt, self.wrap(fn), capture, secure); + return orig.call(this, evt, self.wrap(fn, undefined, before), capture, secure); }; }); fill(proto, 'removeEventListener', function (orig) { return function (evt, fn, capture, secure) { - // from the original function, get the breadcrumb wrapper - // from the breadcrumb wrapper, get the raven wrapper (try/catch) - // i.e. fn => breadcrumb_wrapper(fn) => raven_wrapper(breadcrumb_wrapper(fn)) - fn = fn && (fn.__raven_breadcrumb__ ? fn.__raven_breadcrumb__ : fn); fn = fn && (fn.__raven_wrapper__ ? fn.__raven_wrapper__ : fn); return orig.call(this, evt, fn, capture, secure); }; From 2be35b46184bffbafd384ccbd6b706eda83bbfe8 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 1 Apr 2016 13:35:21 -0700 Subject: [PATCH 11/11] Change captureBreadcrumb api to accept crumb name, crumb data --- src/raven.js | 62 ++++++++++++++++------------------------ test/integration/test.js | 1 - test/raven.test.js | 47 ++++++++++++++++++------------ 3 files changed, 52 insertions(+), 58 deletions(-) diff --git a/src/raven.js b/src/raven.js index e26520031552..ae164dea9152 100644 --- a/src/raven.js +++ b/src/raven.js @@ -354,10 +354,14 @@ Raven.prototype = { return this; }, - captureBreadcrumb: function (obj) { - obj.timestamp = (obj.timestamp || now()) / 1000; + captureBreadcrumb: function (type, data) { + var crumb = { + type: type, + timestamp: now() / 1000, + data: data + }; - this._breadcrumbs.push(obj); + this._breadcrumbs.push(crumb); if (this._breadcrumbs.length > this._breadcrumbLimit) { this._breadcrumbs.shift(); } @@ -631,12 +635,9 @@ Raven.prototype = { self._lastCapturedEvent = evt; var elem = evt.target; - self.captureBreadcrumb({ - type: 'ui_event', - data: { - type: evtName, - target: htmlElementAsString(elem) - } + self.captureBreadcrumb('ui_event', { + type: evtName, + target: htmlElementAsString(elem) }); }; }, @@ -750,10 +751,7 @@ Raven.prototype = { // an exception xhr.__raven_xhr.statusCode = xhr.status; } catch (e) { /* do nothing */ } - self.captureBreadcrumb({ - type: 'http_request', - data: xhr.__raven_xhr - }); + self.captureBreadcrumb('http_request', xhr.__raven_xhr); } return self.wrap(orig); }, true /* noUndo */); // don't track filled methods on XHR instances @@ -769,12 +767,9 @@ Raven.prototype = { // TODO: remove onpopstate handler on uninstall() var oldOnPopState = window.onpopstate; window.onpopstate = function () { - self.captureBreadcrumb({ - type: 'navigation', - data: { - from: self._lastHref, - to: location.href - } + self.captureBreadcrumb('navigation', { + from: self._lastHref, + to: location.href }); // because onpopstate only tells you the "new" (to) value of location.href, and @@ -791,12 +786,9 @@ Raven.prototype = { // params to preserve 0 arity return function(/* state, title, url */) { var url = arguments.length > 2 ? arguments[2] : undefined; - self.captureBreadcrumb({ - type: 'navigation', - data: { - to: url, - from: location.href - } + self.captureBreadcrumb('navigation', { + to: url, + from: location.href }); if (url) self._lastHref = url; return origPushState.apply(this, arguments); @@ -809,13 +801,10 @@ Raven.prototype = { consolePlugin(self, console, { levels: ['debug', 'info', 'warn', 'error', 'log'], callback: function (msg, data) { - self.captureBreadcrumb({ - type: 'message', - data: { - level: data.level, - message: msg - } - }) + self.captureBreadcrumb('message', { + level: data.level, + message: msg + }); } }); } @@ -1147,12 +1136,9 @@ Raven.prototype = { auth.sentry_secret = this._globalSecret; } - this.captureBreadcrumb({ - type: 'sentry', - data: { - message: data.message, - eventId: data.event_id - } + this.captureBreadcrumb('sentry', { + message: data.message, + eventId: data.event_id }); var url = this._globalEndpoint; diff --git a/test/integration/test.js b/test/integration/test.js index 97fa8bb3752c..62de4ed4f68d 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -173,7 +173,6 @@ describe('integration', function () { iframeExecute(iframe, done, function () { setTimeout(done); - debugger; var div = document.createElement('div'); document.body.appendChild(div); diff --git a/test/raven.test.js b/test/raven.test.js index 84bd9858fcf0..0876b49a5438 100644 --- a/test/raven.test.js +++ b/test/raven.test.js @@ -1537,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() { @@ -2153,37 +2161,38 @@ describe('Raven (public API)', function() { describe('.captureBreadcrumb', function () { it('should store the passed object in _breadcrumbs', function() { - var breadcrumb = { - type: 'request', - timestamp: 100, + 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 } - }; - - Raven.captureBreadcrumb(breadcrumb); - - assert.equal(Raven._breadcrumbs[0], breadcrumb); + }); }); it('should dequeue the oldest breadcrumb when over limit', function() { Raven._breadcrumbLimit = 5; Raven._breadcrumbs = [ - { id: 1 }, - { id: 2 }, - { id: 3 }, - { id: 4 }, - { id: 5 } + { 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({ id: 6, timestamp: 100 }); + Raven.captureBreadcrumb('message', { message: 'lol' }); assert.deepEqual(Raven._breadcrumbs, [ - { id: 2 }, - { id: 3 }, - { id: 4 }, - { id: 5 }, - { id: 6, timestamp: 0.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' }}, + { type: 'message', timestamp: 0.1, data: { message: 'lol' }} ]); }); });