diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 324e7752894c..ee717604f148 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -13,6 +13,57 @@ Why are plugins needed at all? JavaScript is pretty restrictive when it comes to exception handling, and there are a lot of things that make it difficult to get relevent information, so it's important that we inject code and wrap things magically so we can extract what we need. See :doc:`/usage/index` for tips regarding that. +Transport plugins +~~~~~~~~~~~~~~~~~ +Transport plugins allow you to override how Raven sends the data into Sentry. +A transport is added based on the dsn passed into config, e.g `Raven.config('http://abc@example.com:80/2')` will use the transport registered for `http`. + +The default transport plugin uses inserts an image thus generating a `GET` request to Sentry. +There is also a transport plugin available for `CORS XHR` in situations where the `Origin` and `Referer` headers won't get set properly for `GET` requests (e.g when the browser is executing from a `file://` URI). + +Registering new transport plugins +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +New methods of transports can be added to Raven using + +.. code-block:: javascript + + Raven.registerTransport('https+post', { + /* + * Configure the transport protocol using the provided config + */ + setup: function(dsn, triggerEvent){ + // snip + }, + + /* + * Send the data to Sentry. + */ + send: function(data, endpoint){ + // snip + } + }); + + +A transport's `setup` method receives a `triggerEvent` parameter which +can be used to trigger an event when the request had succeeded or failed, +using: + +.. code-block:: javascript + + img.onload = function success() { + triggerEvent('success', { + data: data, + src: src + }); + }; + img.onerror = img.onabort = function failure() { + triggerEvent('failure', { + data: data, + src: src + }); + }; + + All Plugins ~~~~~~~~~~~ diff --git a/plugins/v8-xhr.js b/plugins/v8-xhr.js new file mode 100644 index 000000000000..8472437d7262 --- /dev/null +++ b/plugins/v8-xhr.js @@ -0,0 +1,74 @@ +/** + * Chrome/V8 XHR Post plugin + * + * Allows raven-js to send log entries using XHR and CORS for + * environments where the Origin and Referer headers are missing (e.g Chrome plugins). + * + * Usage: Raven.config('https+post://...'); + * */ + +;(function(window, Raven, undefined){ + 'use strict'; + var V8Transport = window.V8Transport = { + setup: function(dsn, triggerEvent){ + if(!this.hasCORS() && window.console && console.error){ + console.error('This browser lacks support for CORS. Falling back to the default transport'); + delete dsn.pass; + HTTPGetTransport.setup(dsn); + return HTTPGetTransport; + } + + if(!dsn.pass) + throw new RavenConfigError('The https+post V8 transport needs the private key to be set in the DSN.'); + + this.triggerEvent = triggerEvent; + this.dsn = dsn; + return this; + }, + + hasCORS: function(){ + return 'withCredentials' in new XMLHttpRequest(); + }, + + getAuthString: function(){ + if (this.cachedAuth) return this.cachedAuth; + + var qs = [ + 'sentry_version=4', + 'sentry_client=raven-js/' + Raven.VERSION, + 'sentry_key=' + this.dsn.user, + 'sentry_secret=' + this.dsn.pass + ]; + + return this.cachedAuth = 'Sentry ' + qs.join(','); + }, + + send: function(data, endpoint){ + var xhr = new XMLHttpRequest(), + triggerEvent = this.triggerEvent; + + + xhr.open('POST', endpoint, true); + xhr.setRequestHeader('X-Sentry-Auth', this.getAuthString()); + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + + xhr.onload = function success() { + triggerEvent('success', { + data: data, + src: endpoint + }); + }; + xhr.onerror = xhr.onabort = function failure() { + triggerEvent('failure', { + data: data, + src: endpoint + }); + }; + + xhr.send(JSON.stringify(data)); + } + } + + Raven.registerTransport('https+post', V8Transport); +}(this, Raven)); + diff --git a/src/raven.js b/src/raven.js index abb43f3e8203..d0ed2a593bb3 100644 --- a/src/raven.js +++ b/src/raven.js @@ -1,5 +1,49 @@ 'use strict'; +/* + * Default transport. + * */ +var HTTPGetTransport = { + setup: function(dsn, triggerEvent){ + if (dsn.pass) + throw new RavenConfigError('Do not specify your private key in the DSN!'); + this.dsn = dsn; + this.triggerEvent = triggerEvent; + }, + send: function(data, endpoint){ + var img = new Image(), + triggerEvent = this.triggerEvent, + src = endpoint + this.getAuthQueryString() + '&sentry_data=' + encodeURIComponent(JSON.stringify(data)); + + img.onload = function success() { + triggerEvent('success', { + data: data, + src: src + }); + }; + img.onerror = img.onabort = function failure() { + triggerEvent('failure', { + data: data, + src: src + }); + }; + img.src = src; + }, + + getAuthQueryString: function() { + if (this.cachedAuth) return this.cachedAuth; + var qs = [ + 'sentry_version=4', + 'sentry_client=raven-js/' + Raven.VERSION + ]; + if (globalKey) { + qs.push('sentry_key=' + this.dsn.user); + } + + return this.cachedAuth = '?' + qs.join('&'); + } +}; + // First, check for JSON support // If there is no JSON, we no-op the core features of Raven // since JSON is required to encode the payload @@ -10,6 +54,8 @@ var _Raven = window.Raven, globalUser, globalKey, globalProject, + globalTransports = { 'default': HTTPGetTransport }, + globalTransport = null, globalOptions = { logger: 'javascript', ignoreErrors: [], @@ -21,6 +67,7 @@ var _Raven = window.Raven, extra: {} }; + /* * The core Raven singleton * @@ -95,7 +142,9 @@ var Raven = { '/' + path + 'api/' + globalProject + '/store/'; if (uri.protocol) { - globalServer = uri.protocol + ':' + globalServer; + // Only use the first part of the url if it contains + // a plugin identifier, e.g https+post -> https + globalServer = uri.protocol.split('+')[0] + ':' + globalServer; } if (globalOptions.fetchContext) { @@ -108,6 +157,13 @@ var Raven = { TraceKit.collectWindowErrors = !!globalOptions.collectWindowErrors; + globalTransport = uri.protocol && globalTransports[uri.protocol] ? globalTransports[uri.protocol] : globalTransports['default']; + + // Allow transports to fall back to other transport protocols, + // if the chosen one isn't supported + var transport = globalTransport.setup(uri, triggerEvent); + globalTransport = transport ? transport : globalTransport; + // return for chaining return Raven; }, @@ -128,6 +184,23 @@ var Raven = { return Raven; }, + + + /* + * Register a transport plugin for pushing data into Sentry. + * Transport plugins are chosen based upon the DSN passed into Raven.configure, + * e.g Raven.configure('http://...') will use the plugin registered for 'http'. + * + * @param {string} protocol A protocol identifier in the DSN, e.g http or https+post + * @param {object} transport An implementation of the transport. Must implement the `setup(dsn)` and `send(data, endpoint)` methods. + */ + registerTransport: function(protocol, transport){ + if(globalTransports[protocol]) throw new RavenConfigError('Protocol ' + protocol + ' already has a registered transport method'); + + globalTransports[protocol] = transport; + return Raven; + }, + /* * Wrap code within a context so Raven can capture errors * reliably across domains that is executed immediately. @@ -321,7 +394,7 @@ function triggerEvent(eventType, options) { } var dsnKeys = 'source protocol user pass host port path'.split(' '), - dsnPattern = /^(?:(\w+):)?\/\/(\w+)(:\w+)?@([\w\.-]+)(?::(\d+))?(\/.*)/; + dsnPattern = /^(?:(\w+|\w+\+\w+)?:)?\/\/(\w+)(?::(\w+))?@([\w\.-]+)(?::(\d+))?(\/.*)/; function RavenConfigError(message) { this.name = 'RavenConfigError'; @@ -334,7 +407,7 @@ RavenConfigError.prototype.constructor = RavenConfigError; function parseDSN(str) { var m = dsnPattern.exec(str), dsn = {}, - i = 7; + i = dsnKeys.length; try { while (i--) dsn[dsnKeys[i]] = m[i] || ''; @@ -342,9 +415,6 @@ function parseDSN(str) { throw new RavenConfigError('Invalid DSN: ' + str); } - if (dsn.pass) - throw new RavenConfigError('Do not specify your private key in the DSN!'); - return dsn; } @@ -395,23 +465,6 @@ function each(obj, callback) { } } -var cachedAuth; - -function getAuthQueryString() { - if (cachedAuth) return cachedAuth; - - var qs = [ - 'sentry_version=4', - 'sentry_client=raven-js/' + Raven.VERSION - ]; - if (globalKey) { - qs.push('sentry_key=' + globalKey); - } - - cachedAuth = '?' + qs.join('&'); - return cachedAuth; -} - function handleStackInfo(stackInfo, options) { var frames = []; @@ -619,22 +672,7 @@ function send(data) { function makeRequest(data) { - var img = new Image(), - src = globalServer + getAuthQueryString() + '&sentry_data=' + encodeURIComponent(JSON.stringify(data)); - - img.onload = function success() { - triggerEvent('success', { - data: data, - src: src - }); - }; - img.onerror = img.onabort = function failure() { - triggerEvent('failure', { - data: data, - src: src - }); - }; - img.src = src; + globalTransport.send(data, globalServer); } function isSetup() { diff --git a/test/index.html b/test/index.html index b5188ed06000..7d1e189ee59f 100644 --- a/test/index.html +++ b/test/index.html @@ -31,6 +31,7 @@ + diff --git a/test/raven.test.js b/test/raven.test.js index 5d99e37dfca5..02aae164f49d 100644 --- a/test/raven.test.js +++ b/test/raven.test.js @@ -5,6 +5,8 @@ function flushRavenState() { globalServer = undefined; globalUser = undefined; globalProject = undefined; + globalTransports = { default: HTTPGetTransport }; + globalTransport = undefined; globalOptions = { logger: 'javascript', ignoreErrors: [], @@ -18,6 +20,7 @@ function flushRavenState() { Raven.uninstall(); } +var noop = function(){}; var imageCache = []; window.Image = function Image() { imageCache.push(this); @@ -29,6 +32,8 @@ if (typeof window.console === 'undefined') { } var SENTRY_DSN = 'http://abc@example.com:80/2'; +var PLUGIN_DSN = 'foo+bar://abc@example.com:80/2'; +var V8_DSN = 'https+post://abc:pwd@example.com:80/2'; function setupRaven() { Raven.config(SENTRY_DSN); @@ -122,6 +127,7 @@ describe('globals', function() { this.sinon.stub(console, 'error'); assert.isFalse(isSetup()); assert.isTrue(console.error.calledOnce); + console.error.restore(); }); it('should return true when everything is all gravy', function() { @@ -130,19 +136,6 @@ describe('globals', function() { }); }); - describe('getAuthQueryString', function() { - it('should return a properly formatted string and cache it', function() { - var expected = '?sentry_version=4&sentry_client=raven-js/<%= pkg.version %>&sentry_key=abc'; - assert.strictEqual(getAuthQueryString(), expected); - assert.strictEqual(cachedAuth, expected); - }); - - it('should return cached value when it exists', function() { - cachedAuth = 'lol'; - assert.strictEqual(getAuthQueryString(), 'lol'); - }); - }); - describe('parseDSN', function() { it('should do what it advertises', function() { var pieces = parseDSN('http://abc@example.com:80/2'); @@ -171,16 +164,6 @@ describe('globals', function() { assert.strictEqual(pieces.host, 'matt-robenolt.com'); }); - it('should raise a RavenConfigError when setting a password', function() { - try { - parseDSN('http://user:pass@example.com/2'); - } catch(e) { - return assert.equal(e.name, 'RavenConfigError'); - } - // shouldn't hit this - assert.isTrue(false); - }); - it('should raise a RavenConfigError with an invalid DSN', function() { try { parseDSN('lol'); @@ -190,6 +173,7 @@ describe('globals', function() { // shouldn't hit this assert.isTrue(false); }); + }); describe('normalizeFrame', function() { @@ -713,7 +697,7 @@ describe('globals', function() { describe('makeRequest', function() { it('should load an Image', function() { imageCache = []; - this.sinon.stub(window, 'getAuthQueryString').returns('?lol'); + this.sinon.stub(globalTransport, 'getAuthQueryString').returns('?lol'); globalServer = 'http://localhost/'; makeRequest({foo: 'bar'}); @@ -1247,4 +1231,185 @@ describe('Raven (public API)', function() { assert.isFalse(TraceKit.report.called); }); }); + + describe('The transport plugin API', function(){ + beforeEach(function() { + setupRaven(); + globalOptions.fetchContext = true; + }); + + afterEach(function() { + flushRavenState(); + }); + + it('should be able to register new transport plugins', function(){ + // Flush raven so we can run .config on it again. + flushRavenState(); + + var setupSpy = this.sinon.spy(); + Raven.registerTransport('foo+bar', { + setup: setupSpy, + send: function(){} + }); + + Raven.config('foo+bar://abc@example.com:80/2'); + + assert.isTrue(setupSpy.calledOnce); + }); + + it('should use the correct plugin, without plugin name when making a request', function(){ + // Flush raven so we can run .config on it again. + flushRavenState(); + + var sendSpy = this.sinon.spy(); + Raven.registerTransport('foo+bar', { + setup: function(dsn){ + return this; + }, + send: sendSpy + }); + + Raven.config('foo+bar://abc@example.com:80/2'); + makeRequest({ foo: 'bar' }) + + assert.deepEqual(sendSpy.args[0][0], { foo: 'bar' }); + assert.strictEqual(sendSpy.args[0][1], 'foo://example.com:80/api/2/store/'); + }); + + it('should run the plugins\' setup method and provide the dsn and triggerEvent function', function(){ + // Flush raven so we can run .config on it again. + flushRavenState(); + + var setupSpy = this.sinon.spy(); + Raven.registerTransport('foo+bar', { + setup: setupSpy, + send: function(){} + }); + + Raven.config(PLUGIN_DSN); + var dsn = parseDSN('foo+bar://abc@example.com:80/2'); + + assert.deepEqual(setupSpy.args[0][0], dsn); + assert.strictEqual(setupSpy.args[0][1], triggerEvent); + }), + + it('should throw an error if a transport plugin is already registerd for a protocol', function(){ + Raven.registerTransport('foo+bar', { + setup: noop, + send: noop + }); + try { + Raven.registerTransport('foo+bar', { + setup: noop, + send: noop + }); + } catch(e){ + return assert.equal(e.name, 'RavenConfigError'); + return assert.equal(e.name, 'Protocol foo+bar already has a registered transport method'); + } + + }); + + it('should allow a plugin to use a fallback (e.g is support is missing)', function(){ + // Flush raven so we can run .config on it again. + flushRavenState(); + + var pluginA = { + setup: noop, + send: noop + }; + var pluginB = { + setup: function(){ + return pluginA; + }, + send: noop + }; + + Raven.registerTransport('foo+bar', pluginB); + Raven.config(PLUGIN_DSN); + + assert.deepEqual(globalTransport, pluginA); + }); + describe('the default transport plugin', function(){ + + it('should set a default transport plugin', function(){ + assert.deepEqual(globalTransport, HTTPGetTransport); + }); + + it('should raise a RavenConfigError when setting a password', function() { + try { + HTTPGetTransport.setup(parseDSN('http://user:pass@example.com/2')); + } catch(e) { + return assert.equal(e.name, 'RavenConfigError'); + } + // shouldn't hit this + assert.isTrue(false); + }); + + describe('getAuthQueryString', function() { + it('should return a properly formatted string and cache it', function() { + var expected = '?sentry_version=4&sentry_client=raven-js/<%= pkg.version %>&sentry_key=abc'; + assert.strictEqual(globalTransport.getAuthQueryString(), expected); + assert.strictEqual(globalTransport.cachedAuth, expected); + }); + + it('should return cached value when it exists', function() { + globalTransport.cachedAuth = 'lol'; + assert.strictEqual(globalTransport.getAuthQueryString(), 'lol'); + }); + }); + + }); + + describe('the v8 transport plugin', function(){ + it('should throw an RavenConfigError if the password is missing', function(){ + flushRavenState(); + Raven.registerTransport('https+post', V8Transport); + try { + Raven.config('https+post://abc@example.com:80/2'); + } catch(e){ + return assert.strictEqual(e.name, 'RavenConfigError'); + return assert.strictEqual(e.name, 'The https+post V8 transport needs the private key to be set in the DSN.'); + } + // We should never hit this + assert.isTrue(false); + }); + + it('should fall back to the GET plugin if CORS isn\'t supported', function(){ + flushRavenState(); + Raven.registerTransport('https+post', V8Transport); + + var stub = this.sinon.stub(V8Transport, 'hasCORS'); + this.sinon.stub(console, 'error'); + stub.returns(false); + + Raven.config(V8_DSN); + + assert.deepEqual(globalTransport, HTTPGetTransport); + assert.isTrue(console.error.calledOnce); + + console.error.restore(); + }); + + it('should send requests using xhr post', function(){ + flushRavenState(); + Raven.registerTransport('https+post', V8Transport); + Raven.config(V8_DSN); + + var xhr = this.sinon.useFakeXMLHttpRequest(); + + makeRequest({ foo: 'bar' }); + + assert.strictEqual(xhr.requests[0].url, 'https://example.com:80/api/2/store/'); + assert.deepEqual(xhr.requests[0].requestHeaders, { + 'X-Sentry-Auth': "Sentry sentry_version=4,sentry_client=raven-js/<%= pkg.version %>,sentry_key=abc,sentry_secret=pwd", + 'Content-Type': "application/json;charset=utf-8" + }); + assert.strictEqual(xhr.requests[0].requestBody, JSON.stringify({ foo: 'bar' })); + + xhr.restore(); + }); + }); + }); + });