Skip to content

Commit 068f9a7

Browse files
committed
Merge pull request #520 from getsentry/breadcrumbs
Capture breadcrumbs and transmit "breadcrumbs"
2 parents f64a87a + 2be35b4 commit 068f9a7

File tree

7 files changed

+462
-13
lines changed

7 files changed

+462
-13
lines changed

plugins/console.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ function consolePlugin(Raven, console, pluginOptions) {
2727
if (l === 'warn') l = 'warning';
2828
return function () {
2929
var args = [].slice.call(arguments);
30-
Raven.captureMessage('' + args.join(' '), {level: l, logger: 'console', extra: { 'arguments': args }});
30+
31+
var msg = '' + args.join(' ');
32+
var data = {level: l, logger: 'console', extra: { 'arguments': args }};
33+
if (pluginOptions.callback) {
34+
pluginOptions.callback(msg, data);
35+
} else {
36+
Raven.captureMessage(msg, data);
37+
}
3138

3239
// this fails for some browsers. :(
3340
if (originalConsoleLevel) {

src/raven.js

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
'use strict';
33

44
var TraceKit = require('../vendor/TraceKit/tracekit');
5+
var consolePlugin = require('../plugins/console');
56
var RavenConfigError = require('./configError');
67
var utils = require('./utils');
78

@@ -16,6 +17,7 @@ var objectMerge = utils.objectMerge;
1617
var truncate = utils.truncate;
1718
var urlencode = utils.urlencode;
1819
var uuid4 = utils.uuid4;
20+
var htmlElementAsString = utils.htmlElementAsString;
1921

2022
var dsnKeys = 'source protocol user pass host port path'.split(' '),
2123
dsnPattern = /^(?:(\w+):)?\/\/(?:(\w+)(:\w+)?@)?([\w\.-]+)(?::(\d+))?(\/.*)/;
@@ -58,6 +60,10 @@ function Raven() {
5860
this._plugins = [];
5961
this._startTime = now();
6062
this._wrappedBuiltIns = [];
63+
this._breadcrumbs = [];
64+
this._breadcrumbLimit = 20;
65+
this._lastCapturedEvent = null;
66+
this._lastHref = window.location && location.href;
6167

6268
for (var method in this._originalConsole) { // eslint-disable-line guard-for-in
6369
this._originalConsoleMethods[method] = this._originalConsole[method];
@@ -198,11 +204,11 @@ Raven.prototype = {
198204
*
199205
* @param {object} options A specific set of options for this context [optional]
200206
* @param {function} func The function to be wrapped in a new context
207+
* @param {function} func A function to call before the try/catch wrapper [optional, private]
201208
* @return {function} The newly wrapped functions with a context
202209
*/
203-
wrap: function(options, func) {
210+
wrap: function(options, func, _before) {
204211
var self = this;
205-
206212
// 1 argument has been passed, and it's not a function
207213
// so just return it
208214
if (isUndefined(func) && !isFunction(options)) {
@@ -241,9 +247,13 @@ Raven.prototype = {
241247
function wrapped() {
242248
var args = [], i = arguments.length,
243249
deep = !options || options && options.deep !== false;
250+
251+
if (_before && isFunction(_before)) {
252+
_before.apply(this, arguments);
253+
}
254+
244255
// Recursively wrap all of a function's arguments that are
245256
// functions themselves.
246-
247257
while(i--) args[i] = deep ? self.wrap(options, arguments[i]) : arguments[i];
248258

249259
try {
@@ -261,10 +271,9 @@ Raven.prototype = {
261271
wrapped[property] = func[property];
262272
}
263273
}
264-
func.__raven_wrapper__ = wrapped;
265-
266274
wrapped.prototype = func.prototype;
267275

276+
func.__raven_wrapper__ = wrapped;
268277
// Signal that this function has been wrapped already
269278
// for both debugging and to prevent it to being wrapped twice
270279
wrapped.__raven__ = true;
@@ -345,6 +354,19 @@ Raven.prototype = {
345354
return this;
346355
},
347356

357+
captureBreadcrumb: function (type, data) {
358+
var crumb = {
359+
type: type,
360+
timestamp: now() / 1000,
361+
data: data
362+
};
363+
364+
this._breadcrumbs.push(crumb);
365+
if (this._breadcrumbs.length > this._breadcrumbLimit) {
366+
this._breadcrumbs.shift();
367+
}
368+
},
369+
348370
addPlugin: function(plugin /*arg1, arg2, ... argN*/) {
349371
var pluginArgs = Array.prototype.slice.call(arguments, 1);
350372

@@ -594,6 +616,32 @@ Raven.prototype = {
594616
}
595617
},
596618

619+
620+
/**
621+
* Wraps addEventListener to capture breadcrumbs
622+
* @param evtName the event name (e.g. "click")
623+
* @param fn the function being wrapped
624+
* @returns {Function}
625+
* @private
626+
*/
627+
_breadcrumbEventHandler: function(evtName) {
628+
var self = this;
629+
return function (evt) {
630+
// It's possible this handler might trigger multiple times for the same
631+
// event (e.g. event propagation through node ancestors). Ignore if we've
632+
// already captured the event.
633+
if (self._lastCapturedEvent === evt)
634+
return;
635+
636+
self._lastCapturedEvent = evt;
637+
var elem = evt.target;
638+
self.captureBreadcrumb('ui_event', {
639+
type: evtName,
640+
target: htmlElementAsString(elem)
641+
});
642+
};
643+
},
644+
597645
/**
598646
* Install any queued plugins
599647
*/
@@ -638,6 +686,13 @@ Raven.prototype = {
638686
});
639687
}
640688

689+
// Capture breadcrubms from any click that is unhandled / bubbled up all the way
690+
// to the document. Do this before we instrument addEventListener.
691+
if (this._hasDocument) {
692+
document.addEventListener('click', self._breadcrumbEventHandler('click'));
693+
694+
}
695+
641696
// event targets borrowed from bugsnag-js:
642697
// https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666
643698
'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 = {
652707
} catch (err) {
653708
// can sometimes get 'Permission denied to access property "handle Event'
654709
}
655-
return orig.call(this, evt, self.wrap(fn), capture, secure);
710+
711+
712+
// TODO: more than just click
713+
var before;
714+
if ((global === 'EventTarget' || global === 'Node') && evt === 'click') {
715+
before = self._breadcrumbEventHandler(evt, fn);
716+
}
717+
return orig.call(this, evt, self.wrap(fn, undefined, before), capture, secure);
656718
};
657719
});
658720
fill(proto, 'removeEventListener', function (orig) {
@@ -665,12 +727,32 @@ Raven.prototype = {
665727
});
666728

667729
if ('XMLHttpRequest' in window) {
668-
fill(XMLHttpRequest.prototype, 'send', function(origSend) {
730+
var xhrproto = XMLHttpRequest.prototype;
731+
fill(xhrproto, 'open', function(origOpen) {
732+
return function (method, url) { // preserve arity
733+
this.__raven_xhr = {
734+
method: method,
735+
url: url,
736+
statusCode: null
737+
};
738+
return origOpen.apply(this, arguments);
739+
};
740+
});
741+
742+
fill(xhrproto, 'send', function(origSend) {
669743
return function (data) { // preserve arity
670744
var xhr = this;
671745
'onreadystatechange onload onerror onprogress'.replace(/\w+/g, function (prop) {
672-
if (prop in xhr && Object.prototype.toString.call(xhr[prop]) === '[object Function]') {
746+
if (prop in xhr && isFunction(xhr[prop])) {
673747
fill(xhr, prop, function (orig) {
748+
if (prop === 'onreadystatechange' && xhr.__raven_xhr && (xhr.readyState === 1 || xhr.readyState === 4)) {
749+
try {
750+
// touching statusCode in some platforms throws
751+
// an exception
752+
xhr.__raven_xhr.statusCode = xhr.status;
753+
} catch (e) { /* do nothing */ }
754+
self.captureBreadcrumb('http_request', xhr.__raven_xhr);
755+
}
674756
return self.wrap(orig);
675757
}, true /* noUndo */); // don't track filled methods on XHR instances
676758
}
@@ -680,6 +762,53 @@ Raven.prototype = {
680762
});
681763
}
682764

765+
// record navigation (URL) changes
766+
if ('history' in window && history.pushState) {
767+
// TODO: remove onpopstate handler on uninstall()
768+
var oldOnPopState = window.onpopstate;
769+
window.onpopstate = function () {
770+
self.captureBreadcrumb('navigation', {
771+
from: self._lastHref,
772+
to: location.href
773+
});
774+
775+
// because onpopstate only tells you the "new" (to) value of location.href, and
776+
// not the previous (from) value, we need to track the value of location.href
777+
// ourselves
778+
self._lastHref = location.href;
779+
if (oldOnPopState) {
780+
return oldOnPopState.apply(this, arguments);
781+
}
782+
};
783+
784+
fill(history, 'pushState', function (origPushState) {
785+
// note history.pushState.length is 0; intentionally not declaring
786+
// params to preserve 0 arity
787+
return function(/* state, title, url */) {
788+
var url = arguments.length > 2 ? arguments[2] : undefined;
789+
self.captureBreadcrumb('navigation', {
790+
to: url,
791+
from: location.href
792+
});
793+
if (url) self._lastHref = url;
794+
return origPushState.apply(this, arguments);
795+
}
796+
});
797+
}
798+
799+
// console
800+
if ('console' in window && console.log) {
801+
consolePlugin(self, console, {
802+
levels: ['debug', 'info', 'warn', 'error', 'log'],
803+
callback: function (msg, data) {
804+
self.captureBreadcrumb('message', {
805+
level: data.level,
806+
message: msg
807+
});
808+
}
809+
});
810+
}
811+
683812
var $ = window.jQuery || window.$;
684813
if ($ && $.fn && $.fn.ready) {
685814
fill($.fn, 'ready', function (orig) {
@@ -952,6 +1081,12 @@ Raven.prototype = {
9521081
// Send along our own collected metadata with extra
9531082
data.extra['session:duration'] = now() - this._startTime;
9541083

1084+
if (this._breadcrumbs && this._breadcrumbs.length > 0) {
1085+
data.breadcrumbs = {
1086+
values: this._breadcrumbs
1087+
};
1088+
}
1089+
9551090
// If there are no tags/extra, strip the key from the payload alltogther.
9561091
if (isEmptyObject(data.tags)) delete data.tags;
9571092

@@ -1001,6 +1136,11 @@ Raven.prototype = {
10011136
auth.sentry_secret = this._globalSecret;
10021137
}
10031138

1139+
this.captureBreadcrumb('sentry', {
1140+
message: data.message,
1141+
eventId: data.event_id
1142+
});
1143+
10041144
var url = this._globalEndpoint;
10051145
(globalOptions.transport || this._makeRequest).call(this, {
10061146
url: url,

src/utils.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,25 @@ function uuid4() {
140140
}
141141
}
142142

143+
/**
144+
* Returns a simple, child-less string representation of a DOM element
145+
* e.g. [HTMLElement] => <input class="btn" />
146+
* @param HTMLElement
147+
*/
148+
function htmlElementAsString(elem) {
149+
var out = ['<'];
150+
out.push(elem.tagName.toLowerCase());
151+
var attrWhitelist = ['id', 'type', 'name', 'value', 'class', 'placeholder', 'title', 'alt'];
152+
each(attrWhitelist, function(index, key) {
153+
var attr = elem.getAttribute(key);
154+
if (attr) {
155+
out.push(' ' + key + '="' + attr + '"');
156+
}
157+
});
158+
out.push(' />');
159+
return out.join('');
160+
}
161+
143162
module.exports = {
144163
isUndefined: isUndefined,
145164
isFunction: isFunction,
@@ -153,5 +172,6 @@ module.exports = {
153172
hasKey: hasKey,
154173
joinRegExp: joinRegExp,
155174
urlencode: urlencode,
156-
uuid4: uuid4
175+
uuid4: uuid4,
176+
htmlElementAsString: htmlElementAsString
157177
};

test/integration/frame.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,13 @@
7373
</script>
7474
</head>
7575
<body>
76+
<!-- test element for breadcrumbs -->
77+
<input name="foo" id="bar" placeholder="lol"/>
78+
79+
<div id="c">
80+
<div id="b">
81+
<div id="a"/>
82+
</div>
83+
</div>
7684
</body>
7785
</html>

0 commit comments

Comments
 (0)