diff --git a/.travis.yml b/.travis.yml index d2c62a9..945739e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ +branches: + only: + - master sudo: false language: node_js node_js: @@ -6,4 +9,7 @@ node_js: - "6" - "7" - "8" +cache: + directories: + - node_modules script: npm run test-full diff --git a/History.md b/History.md index 8b4a323..d7c23bd 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,17 @@ +# 2.2.1 - 10/02/2017 + +- Remove unintentional memwatch-next dependency + +# 2.2.0 - 10/02/2017 +- Fixed all Node v8 tests to ensure everything is working correctly [See #366] +- Raven now uses Prettier to format it's code [See #364] +- Prevent Raven from queueing too many requests in case server is down [See #132] +- Enable keep-alive on socket connection and limit number of sockets [See #284] +- Pull Error's name from constructor, not Error itself to always get correct error type [See #372] +- Updated Errors serialization to store all additional properties and allow for attaching other object instances directly to it [See #376] +- Preserve some non-enumerable properties from request [See #379] +- Fall back to NODE_ENV for Sentry Environment [See #384] + # 2.1.2 - 8/16/2017 - Remove errant large file that accidentally got published in 2.1.1. [See #361] diff --git a/docs/config.rst b/docs/config.rst index 9e40b0f..77c8360 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -6,10 +6,20 @@ To get started, you need to configure Raven to use your Sentry DSN: .. sourcecode:: javascript var Raven = require('raven'); - Raven.config('___PUBLIC_DSN___').install() + Raven.config('___PUBLIC_DSN___').install(); At this point, Raven is ready to capture any uncaught exceptions. +Note that the ``install`` method can optionally take a callback function that is invoked if a fatal, non-recoverable error occurs. +You can use this callback to perform any cleanup that should occur before the Node process exits. + +.. sourcecode:: javascript + + Raven.config('___PUBLIC_DSN___').install(function (err, initialErr, eventId) { + console.error(err); + process.exit(1); + }); + Optional settings ----------------- @@ -18,8 +28,8 @@ Optional settings .. sourcecode:: javascript Raven.config('___PUBLIC_DSN___', { - release: '1.3.0' - }).install() + release: '1.3.0' + }).install(); Those configuration options are documented below: @@ -33,9 +43,21 @@ Those configuration options are documented below: logger: 'default' } +.. describe:: name + + Set the server name for the client to use. Default: ``require('os').hostname()`` + Optionally, use ``SENTRY_NAME`` environment variable. + + .. code-block:: javascript + + { + name: 'primary' + } + .. describe:: release Track the version of your application in Sentry. + Optionally, use ``SENTRY_RELEASE`` environment variable. .. code-block:: javascript @@ -43,9 +65,22 @@ Those configuration options are documented below: release: '721e41770371db95eee98ca2707686226b993eda' } + This is usually a Git SHA hash, which can be obtained using various npm packages, e.g. + + .. code-block:: javascript + + var git = require('git-rev-sync'); + + { + // this will return 40 characters long hash + // eg. '75bf4eea9aa1a7fd6505d0d0aa43105feafa92ef' + release: git.long() + } + .. describe:: environment Track the environment name inside Sentry. + Optionally, use ``SENTRY_ENVIRONMENT`` environment variable. .. code-block:: javascript @@ -195,6 +230,14 @@ Those configuration options are documented below: Please see the raven-node source code to see `how transports are implemented `__. +.. describe:: maxReqQueueCount + + Controls how many requests can be maximally queued before bailing out and emitting an error. Defaults to `100`. + +.. describe:: stacktrace + + Attach stack trace to `captureMessage` calls by generatic "synthetic" error object and extracting all frames. + Environment Variables --------------------- @@ -204,8 +247,7 @@ Environment Variables .. describe:: SENTRY_NAME - Optionally set the name for the client to use. `What is name? - `__ + Optionally set the server name for the client to use. .. describe:: SENTRY_RELEASE @@ -213,4 +255,4 @@ Environment Variables .. describe:: SENTRY_ENVIRONMENT - Optionally set the environment name, e.g. "staging", "production". + Optionally set the environment name, e.g. "staging", "production". Sentry will default to the value of `NODE_ENV`, if present. diff --git a/docs/integrations/index.rst b/docs/integrations/index.rst index b2fcb57..1cfe746 100644 --- a/docs/integrations/index.rst +++ b/docs/integrations/index.rst @@ -7,3 +7,5 @@ Integrations connect express koa + loopback + sails diff --git a/docs/integrations/loopback.rst b/docs/integrations/loopback.rst new file mode 100644 index 0000000..576eae7 --- /dev/null +++ b/docs/integrations/loopback.rst @@ -0,0 +1,21 @@ +Loopback +======== + +.. code-block:: javascript + + // server/middleware/sentry.js + + var Raven = require('raven'); + Raven.config('__DSN__').install(); + + module.exports = function () { + return Raven.errorHandler(); + } + +.. code-block:: javascript + + // server/middleware.json + + "final": { + "./middleware/sentry": {} + } diff --git a/docs/integrations/sails.rst b/docs/integrations/sails.rst new file mode 100644 index 0000000..cd5a6b5 --- /dev/null +++ b/docs/integrations/sails.rst @@ -0,0 +1,30 @@ +Sails +===== + +.. code-block:: javascript + + // config/http.js + + var Raven = require('raven'); + Raven.config('__DSN__').install(); + + module.exports.http = { + middleware: { + // Raven's handlers has to be added as a keys to http.middleware config object + requestHandler: Raven.requestHandler(), + errorHandler: Raven.errorHandler(), + + // And they have to be added in a correct order to middlewares list + order: [ + // The request handler must be the very first one + 'requestHandler', + // ...more middlewares + 'router', + // The error handler must be after router, but before any other error middleware + 'errorHandler', + /// ...remaining middlewares + ] + } + } + + diff --git a/docs/usage.rst b/docs/usage.rst index f236b25..10027db 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -136,10 +136,11 @@ All optional attributes are passed as part of the options to ``captureException` If you're inside a context and your context data includes a ``user`` key, that data will be merged into this. -.. describe:: req +.. describe:: request - The ``req`` object associated with this event, from a Node http server, Express, Koa, or similar. - Will be parsed for request details and user context from ``req.user`` if present. + Alias: ``req``. The ``request`` object associated with this event, from a Node http server, Express, Koa, or similar. + Will be parsed for request details and user context from ``request.user`` if present. It will only pull out the data + that's handled by the server: ``headers``, ``method``, ``host``, ``protocol``, ``url``, ``query``, ``cookies``, ``body``, ``ip`` and ``user``. .. code-block:: javascript @@ -299,7 +300,7 @@ It can do anything necessary, including asynchronous operations, to make a best not throw, and it absolutely must not allow the process to keep running indefinitely. This means it should probably make an explicit ``process.exit()`` call. After catching a fatal exception, Raven will make a best-effort attempt to send it to Sentry before it calls the fatal exception handler. -If sending fails, a ``sendErr`` error object will be passed, and otherwise the ``eventId`` will be provided. In either case, the error object +If sending fails, a ``sendErr`` error object will be passed, and otherwise the ``eventId`` will be provided. In either case, the error object resulting in the shutdown is passed as the first parameter. .. code-block:: javascript diff --git a/lib/client.js b/lib/client.js index 2845d19..d7644ed 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,6 +1,6 @@ 'use strict'; -var stringify = require('json-stringify-safe'); +var stringify = require('../vendor/json-stringify-safe'); var parsers = require('./parsers'); var zlib = require('zlib'); var utils = require('./utils'); @@ -54,7 +54,8 @@ extend(Raven.prototype, { this.transport = options.transport || transports[this.dsn.protocol]; this.sendTimeout = options.sendTimeout || 1; this.release = options.release || process.env.SENTRY_RELEASE || ''; - this.environment = options.environment || process.env.SENTRY_ENVIRONMENT || ''; + this.environment = + options.environment || process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || ''; // autoBreadcrumbs: true enables all, autoBreadcrumbs: false disables all // autoBreadcrumbs: { http: true } enables a single type @@ -67,7 +68,9 @@ extend(Raven.prototype, { this.dataCallback = options.dataCallback; this.shouldSendCallback = options.shouldSendCallback; this.sampleRate = typeof options.sampleRate === 'undefined' ? 1 : options.sampleRate; + this.maxReqQueueCount = options.maxReqQueueCount || 100; this.parseUser = options.parseUser; + this.stacktrace = options.stacktrace || false; if (!this.dsn) { utils.consoleAlert('no DSN provided, error reporting disabled'); @@ -227,15 +230,18 @@ extend(Raven.prototype, { but also want to provide convenience for passing a req object and having us parse it out so we only parse a `req` property if the `request` property is absent/empty (and hence we won't clobber) parseUser returns a partial kwargs object with a `request` property and possibly a `user` property - */ - kwargs.request = extend( - {}, + */ + kwargs.request = this._createRequestObject( this._globalContext.request, domainContext.request, kwargs.request ); if (Object.keys(kwargs.request).length === 0) { - var req = extend({}, this._globalContext.req, domainContext.req, kwargs.req); + var req = this._createRequestObject( + this._globalContext.req, + domainContext.req, + kwargs.req + ); if (Object.keys(req).length > 0) { var parseUser = Object.keys(kwargs.user).length === 0 ? this.parseUser : false; extend(kwargs, parsers.parseRequest(req, parseUser)); @@ -313,8 +319,31 @@ extend(Raven.prototype, { } else { kwargs = kwargs || {}; } + var eventId = this.generateEventId(); - this.process(eventId, parsers.parseText(message, kwargs), cb); + + if (this.stacktrace) { + var ex; + // Generate a "synthetic" stack trace + try { + throw new Error(message); + } catch (ex1) { + ex = ex1; + } + + utils.parseStack( + ex, + function(frames) { + // We trim last frame, as it's our `throw new Error(message)` call itself, which is redundant + kwargs.stacktrace = { + frames: frames.slice(0, -1) + }; + this.process(eventId, parsers.parseText(message, kwargs), cb); + }.bind(this) + ); + } else { + this.process(eventId, parsers.parseText(message, kwargs), cb); + } return eventId; }, @@ -512,6 +541,47 @@ extend(Raven.prototype, { currCtx.breadcrumbs.shift(); } this.setContext(currCtx); + }, + + _createRequestObject: function() { + /** + * When using proxy, some of the attributes of req/request objects are non-enumerable. + * To make sure, that they are still available to us after we consolidate our sources + * (eg. globalContext.request + domainContext.request + kwargs.request), + * we manually pull them out from original objects. + * + * Same scenario happens when some frameworks (eg. Koa) decide to use request within + * request. eg `this.request.req`, which adds aliases to the main `request` object. + * By manually reassigning them here, we don't need to add additional checks + * like `req.method || (req.req && req.req.method)` + * + * We don't use Object.assign/extend as it's only merging over objects own properties, + * and we don't want to go through all of the properties as well, as we simply don't + * need all of them. + **/ + var sources = Array.from(arguments).filter(function(source) { + return Object.prototype.toString.call(source) === '[object Object]'; + }); + sources = [{}].concat(sources); + var request = extend.apply(null, sources); + var nonEnumberables = [ + 'headers', + 'host', + 'ip', + 'method', + 'protocol', + 'query', + 'secure', + 'url' + ]; + + nonEnumberables.forEach(function(key) { + sources.forEach(function(source) { + if (source[key]) request[key] = source[key]; + }); + }); + + return request; } }); diff --git a/lib/parsers.js b/lib/parsers.js index d0e07ed..3135e1b 100644 --- a/lib/parsers.js +++ b/lib/parsers.js @@ -2,7 +2,7 @@ var cookie = require('cookie'); var urlParser = require('url'); -var stringify = require('json-stringify-safe'); +var stringify = require('../vendor/json-stringify-safe'); var utils = require('./utils'); @@ -15,7 +15,8 @@ module.exports.parseText = function parseText(message, kwargs) { module.exports.parseError = function parseError(err, kwargs, cb) { utils.parseStack(err, function(frames) { - var name = err.name + ''; + var name = + ({}.hasOwnProperty.call(err, 'name') ? err.name : err.constructor.name) + ''; if (typeof kwargs.message === 'undefined') { kwargs.message = name + ': ' + (err.message || ''); } diff --git a/lib/transports.js b/lib/transports.js index ac572f6..3a92b79 100644 --- a/lib/transports.js +++ b/lib/transports.js @@ -6,6 +6,14 @@ var timeoutReq = require('timed-out'); var http = require('http'); var https = require('https'); +var tunnel = require('tunnel-agent'); + +var agentOptions = { + keepAlive: true, + maxSockets: 100 +}; +var httpAgent = new http.Agent(agentOptions); +var httpsAgent = new https.Agent(agentOptions); function Transport() {} util.inherits(Transport, events.EventEmitter); @@ -14,24 +22,41 @@ function HTTPTransport(options) { this.defaultPort = 80; this.transport = http; this.options = options || {}; + this.agent = httpAgent; } util.inherits(HTTPTransport, Transport); -HTTPTransport.prototype.send = function(client, message, headers, eventId, cb) { +HTTPTransport.prototype.send = function (client, message, headers, eventId, cb) { + var options = { hostname: client.dsn.host, path: client.dsn.path + 'api/' + client.dsn.project_id + '/store/', headers: headers, method: 'POST', port: client.dsn.port || this.defaultPort, - ca: client.ca + ca: client.ca, + agent: this.agent }; + + // set path apprpriately when using http endpoint + proxy, set proxy headers appropriately when using https endpoint + proxy + if (this.options.hasOwnProperty('proxyHost')) { + if (client.dsn.protocol === 'http') { + options.path = 'http://' + client.dsn.host + ':' + client.dsn.port + client.dsn.path + 'api/' + client.dsn.project_id + '/store/'; + delete options.hostname; // only 'host' should be set when using proxy + } else { + this.options.agent.proxyOptions.headers = { + 'Content-Type': 'application/octet-stream', + host: client.dsn.host + ':' + client.dsn.port + } + } + } + for (var key in this.options) { if (this.options.hasOwnProperty(key)) { options[key] = this.options[key]; } } - var req = this.transport.request(options, function(res) { + var req = this.transport.request(options, function (res) { res.setEncoding('utf8'); if (res.statusCode >= 200 && res.statusCode < 300) { client.emit('logged', eventId); @@ -49,7 +74,7 @@ HTTPTransport.prototype.send = function(client, message, headers, eventId, cb) { cb && cb(e); } // force the socket to drain - var noop = function() {}; + var noop = function () {}; res.on('data', noop); res.on('end', noop); }); @@ -57,13 +82,14 @@ HTTPTransport.prototype.send = function(client, message, headers, eventId, cb) { timeoutReq(req, client.sendTimeout * 1000); var cbFired = false; - req.on('error', function(e) { + req.on('error', function (e) { client.emit('error', e); if (!cbFired) { cb && cb(e); cbFired = true; } }); + req.end(message); }; @@ -72,10 +98,38 @@ function HTTPSTransport(options) { this.transport = https; this.options = options || {}; } + +function HTTPProxyTransport(options) { + // this.defaultPort = 80; + this.transport = http; + this.options = options || {}; + this.options.host = options.proxyHost; + this.options.port = options.proxyPort; +} + +function HTTPSProxyTransport(options) { + this.transport = https; + this.options = options || {}; + this.options.agent = tunnel['httpsOverHttp']({ + proxy: { + host: options.proxyHost, + port: options.proxyPort, + proxyAuth: null // TODO: Add ability to specify creds/auth + }, + keepAlive: agentOptions.keepAlive, + maxSockets: agentOptions.maxSockets + }); +} + util.inherits(HTTPSTransport, HTTPTransport); +util.inherits(HTTPProxyTransport, HTTPTransport); +util.inherits(HTTPSProxyTransport, HTTPTransport); module.exports.http = new HTTPTransport(); module.exports.https = new HTTPSTransport(); module.exports.Transport = Transport; module.exports.HTTPTransport = HTTPTransport; module.exports.HTTPSTransport = HTTPSTransport; + +module.exports.HTTPProxyTransport = HTTPProxyTransport; +module.exports.HTTPSProxyTransport = HTTPSProxyTransport; diff --git a/lib/utils.js b/lib/utils.js index 187e3ef..1de741f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -52,7 +52,7 @@ module.exports.getAuthHeader = function getAuthHeader(timestamp, apiKey, apiSecr header.push('sentry_timestamp=' + timestamp); header.push('sentry_client=raven-node/' + ravenVersion); header.push('sentry_key=' + apiKey); - header.push('sentry_secret=' + apiSecret); + if (apiSecret) header.push('sentry_secret=' + apiSecret); return header.join(', '); }; @@ -66,10 +66,13 @@ module.exports.parseDSN = function parseDSN(dsn) { response = { protocol: parsed.protocol.slice(0, -1), public_key: parsed.auth.split(':')[0], - private_key: parsed.auth.split(':')[1], host: parsed.host.split(':')[0] }; + if (parsed.auth.split(':')[1]) { + response.private_key = parsed.auth.split(':')[1]; + } + if (~response.protocol.indexOf('+')) { response.protocol = response.protocol.split('+')[1]; } diff --git a/package.json b/package.json index 8407546..0899449 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "raven", "sentry" ], - "version": "2.1.2", + "version": "2.2.1", "repository": "git://github.com/getsentry/raven-node.git", "license": "BSD-2-Clause", "homepage": "https://github.com/getsentry/raven-node", @@ -31,10 +31,10 @@ }, "dependencies": { "cookie": "0.3.1", - "json-stringify-safe": "5.0.1", "lsmod": "1.0.0", "stack-trace": "0.0.9", "timed-out": "4.0.1", + "tunnel-agent": "^0.6.0", "uuid": "3.0.0" }, "devDependencies": { @@ -46,12 +46,12 @@ "glob": "~3.1.13", "husky": "^0.14.3", "istanbul": "^0.4.3", - "koa": "*", "lint-staged": "^4.0.4", "mocha": "~3.1.2", "nock": "~9.0.0", "prettier": "^1.6.1", - "should": "11.2.0" + "should": "11.2.0", + "sinon": "^3.3.0" }, "prettier": { "singleQuote": true, diff --git a/test/raven.client.js b/test/raven.client.js index 3271939..1a6666a 100644 --- a/test/raven.client.js +++ b/test/raven.client.js @@ -199,6 +199,21 @@ describe('raven.Client', function() { client.captureMessage('Hey!'); }); + + it('should allow for attaching stacktrace', function(done) { + var dsn = 'https://public:private@app.getsentry.com:8443/269'; + var client = new raven.Client(dsn, { + stacktrace: true + }); + client.send = function mockSend(kwargs) { + kwargs.message.should.equal('wtf?'); + kwargs.should.have.property('stacktrace'); + var stack = kwargs.stacktrace; + stack.frames[stack.frames.length - 1].context_line.should.match(/captureMessage/); + done(); + }; + client.captureMessage('wtf?'); + }); }); describe('#captureException()', function() { @@ -1369,6 +1384,102 @@ describe('raven.Client', function() { }); }); }); + + describe('#_createRequestObject', function() { + it('should merge together all sources', function() { + var req = client._createRequestObject( + { + foo: 123 + }, + { + bar: 42 + } + ); + var expected = { + foo: 123, + bar: 42 + }; + req.should.eql(expected); + }); + + it('should preserve extend-like order', function() { + var req = client._createRequestObject( + { + foo: 111 + }, + { + foo: 222 + }, + { + foo: 333 + } + ); + var expected = { + foo: 333 + }; + req.should.eql(expected); + }); + + it('should filter incorrect sources', function() { + var req = client._createRequestObject( + { + foo: 111 + }, + [42], + null, + 'hello', + { + foo: 222 + } + ); + var expected = { + foo: 222 + }; + req.should.eql(expected); + }); + + it('should extract specified non-enumerables', function() { + var foo = {}; + Object.defineProperty(foo, 'ip', { + value: '127.0.0.1', + enumerable: false + }); + var bar = { + foo: 222 + }; + var req = client._createRequestObject(foo, bar); + var expected = { + foo: 222, + ip: '127.0.0.1' + }; + req.should.eql(expected); + }); + + it('should skip all remaining non-enumerables', function() { + var foo = {}; + Object.defineProperty(foo, 'ip', { + value: '127.0.0.1', + enumerable: false + }); + Object.defineProperty(foo, 'pickle', { + value: 'rick', + enumerable: false + }); + var bar = { + dont: 'skip' + }; + Object.defineProperty(bar, 'evil', { + value: 'morty', + enumerable: false + }); + var req = client._createRequestObject(foo, bar); + var expected = { + ip: '127.0.0.1', + dont: 'skip' + }; + req.should.eql(expected); + }); + }); }); describe('raven requestHandler/errorHandler middleware', function() { diff --git a/test/raven.parsers.js b/test/raven.parsers.js index 4380254..0edf122 100644 --- a/test/raven.parsers.js +++ b/test/raven.parsers.js @@ -1,5 +1,6 @@ 'use strict'; +var util = require('util'); var assert = require('assert'); var raven = require('../'); @@ -528,19 +529,6 @@ describe('raven.parsers', function() { }); }); - it('should parse Error with non-string type', function(done) { - var err = new Error(); - err.name = {}; - raven.parsers.parseError(err, {}, function(parsed) { - parsed.message.should.equal('[object Object]: '); - parsed.should.have.property('exception'); - parsed.exception[0].type.should.equal('[object Object]'); - parsed.exception[0].value.should.equal(''); - parsed.exception[0].stacktrace.should.have.property('frames'); - done(); - }); - }); - it('should parse thrown Error', function(done) { try { throw new Error('Derp'); @@ -619,5 +607,71 @@ describe('raven.parsers', function() { }); } }); + + it('should read name from an Error constructor', function(done) { + var err = new Error(); + raven.parsers.parseError(err, {}, function(parsed) { + parsed.message.should.equal('Error: '); + parsed.exception[0].type.should.equal('Error'); + done(); + }); + }); + + it('should read name from an Error constructor for custom errors', function(done) { + // https://gist.github.com/justmoon/15511f92e5216fa2624b + function CustomError(message) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.message = message; + } + + util.inherits(CustomError, Error); + + var err = new CustomError('boom'); + raven.parsers.parseError(err, {}, function(parsed) { + parsed.message.should.equal('CustomError: boom'); + parsed.exception[0].type.should.equal('CustomError'); + done(); + }); + }); + + it('should allow for overriding name for built-in errors', function(done) { + var err = new Error(); + err.name = 'foo'; + raven.parsers.parseError(err, {}, function(parsed) { + parsed.message.should.equal('foo: '); + parsed.exception[0].type.should.equal('foo'); + done(); + }); + }); + + it('should allow for overriding name for custom errors', function(done) { + // https://gist.github.com/justmoon/15511f92e5216fa2624b + function CustomError(message) { + Error.captureStackTrace(this, this.constructor); + this.name = 'foo'; + this.message = message; + } + + util.inherits(CustomError, Error); + + var err = new CustomError('boom'); + raven.parsers.parseError(err, {}, function(parsed) { + parsed.message.should.equal('foo: boom'); + parsed.exception[0].type.should.equal('foo'); + done(); + }); + }); + + it('should parse Error with non-string type', function(done) { + var err = new Error(); + err.name = {}; + raven.parsers.parseError(err, {}, function(parsed) { + parsed.message.should.equal('[object Object]: '); + parsed.exception[0].type.should.equal('[object Object]'); + parsed.exception[0].value.should.equal(''); + done(); + }); + }); }); }); diff --git a/test/raven.transports.js b/test/raven.transports.js new file mode 100644 index 0000000..87da3dc --- /dev/null +++ b/test/raven.transports.js @@ -0,0 +1,50 @@ +'use strict'; + +var transports = require('../lib/transports'); + +describe('transports', function() { + it('should have an http/s agent with correct config attached by default', function() { + var http = transports.http; + http.agent.should.exist; + http.agent.keepAlive.should.equal(true); + http.agent.maxSockets.should.equal(100); + + var https = transports.https; + https.agent.should.exist; + https.agent.keepAlive.should.equal(true); + https.agent.maxSockets.should.equal(100); + }); + + it('should emit error when requests queued over the limit', function(done) { + var http = transports.http; + var _cachedAgent = http.options.agent; + + var requests = {}; + for (var i = 0; i < 10; i++) { + requests[i] = 'req'; + } + + http.agent = Object.assign({}, _cachedAgent, { + getName: function() { + return 'foo:123'; + }, + requests: { + 'foo:123': requests + } + }); + + http.send({ + dsn: { + host: 'foo', + port: 123 + }, + maxReqQueueCount: 5, + emit: function(event, body) { + event.should.equal('error'); + body.message.should.equal('client req queue is full..'); + http.options.agent = _cachedAgent; + done(); + } + }); + }); +}); diff --git a/test/raven.utils.js b/test/raven.utils.js index a7ff560..a8f9ca5 100644 --- a/test/raven.utils.js +++ b/test/raven.utils.js @@ -81,6 +81,21 @@ describe('raven.utils', function() { }; dsn.should.eql(expected); }); + + it('should parse DSN without private key', function() { + var dsn = raven.utils.parseDSN( + 'https://8769c40cf49c4cc58b51fa45d8e2d166@mysentry.com:8443/some/other/path/269' + ); + var expected = { + protocol: 'https', + public_key: '8769c40cf49c4cc58b51fa45d8e2d166', + host: 'mysentry.com', + path: '/some/other/path/', + project_id: '269', + port: 8443 + }; + dsn.should.eql(expected); + }); }); describe('#parseAuthHeader()', function() { @@ -94,6 +109,16 @@ describe('raven.utils', function() { ', sentry_key=abc, sentry_secret=xyz'; raven.utils.getAuthHeader(timestamp, apiKey, apiSecret).should.equal(expected); }); + + it('should skip sentry_secret if apiSecret not provided', function() { + var timestamp = 12345, + apiKey = 'abc'; + var expected = + 'Sentry sentry_version=5, sentry_timestamp=12345, sentry_client=raven-node/' + + raven.version + + ', sentry_key=abc'; + raven.utils.getAuthHeader(timestamp, apiKey).should.equal(expected); + }); }); describe('#parseStack()', function() { diff --git a/test/run.coffee b/test/run.coffee index c1191e8..4228d65 100644 --- a/test/run.coffee +++ b/test/run.coffee @@ -6,10 +6,11 @@ path = require('path') mocha = new Mocha -fs.readdirSync('test').filter (file) -> - return file.substr(-3) == '.js'; -.forEach (file) -> - mocha.addFile path.join('test', file) +['test', 'test/vendor'].forEach (dir) -> + fs.readdirSync(dir).filter (file) -> + file.substr(-3) == '.js'; + .forEach (file) -> + mocha.addFile path.join(dir, file) mocha.run (failures) -> process.on 'exit', () -> diff --git a/test/vendor/json-stringify-safe.js b/test/vendor/json-stringify-safe.js new file mode 100644 index 0000000..9934838 --- /dev/null +++ b/test/vendor/json-stringify-safe.js @@ -0,0 +1,337 @@ +'use strict'; + +var Sinon = require('sinon'); +var assert = require('assert'); +var stringify = require('../../vendor/json-stringify-safe'); + +function jsonify(obj) { + return JSON.stringify(obj, null, 2); +} + +describe('Stringify', function() { + it('must stringify circular objects', function() { + var obj = {name: 'Alice'}; + obj.self = obj; + var json = stringify(obj, null, 2); + assert.deepEqual(json, jsonify({name: 'Alice', self: '[Circular ~]'})); + }); + + it('must stringify circular objects with intermediaries', function() { + var obj = {name: 'Alice'}; + obj.identity = {self: obj}; + var json = stringify(obj, null, 2); + assert.deepEqual(json, jsonify({name: 'Alice', identity: {self: '[Circular ~]'}})); + }); + + it('must stringify circular objects deeper', function() { + var obj = {name: 'Alice', child: {name: 'Bob'}}; + obj.child.self = obj.child; + + assert.deepEqual( + stringify(obj, null, 2), + jsonify({ + name: 'Alice', + child: {name: 'Bob', self: '[Circular ~.child]'} + }) + ); + }); + + it('must stringify circular objects deeper with intermediaries', function() { + var obj = {name: 'Alice', child: {name: 'Bob'}}; + obj.child.identity = {self: obj.child}; + + assert.deepEqual( + stringify(obj, null, 2), + jsonify({ + name: 'Alice', + child: {name: 'Bob', identity: {self: '[Circular ~.child]'}} + }) + ); + }); + + it('must stringify circular objects in an array', function() { + var obj = {name: 'Alice'}; + obj.self = [obj, obj]; + + assert.deepEqual( + stringify(obj, null, 2), + jsonify({ + name: 'Alice', + self: ['[Circular ~]', '[Circular ~]'] + }) + ); + }); + + it('must stringify circular objects deeper in an array', function() { + var obj = {name: 'Alice', children: [{name: 'Bob'}, {name: 'Eve'}]}; + obj.children[0].self = obj.children[0]; + obj.children[1].self = obj.children[1]; + + assert.deepEqual( + stringify(obj, null, 2), + jsonify({ + name: 'Alice', + children: [ + {name: 'Bob', self: '[Circular ~.children.0]'}, + {name: 'Eve', self: '[Circular ~.children.1]'} + ] + }) + ); + }); + + it('must stringify circular arrays', function() { + var obj = []; + obj.push(obj); + obj.push(obj); + var json = stringify(obj, null, 2); + assert.deepEqual(json, jsonify(['[Circular ~]', '[Circular ~]'])); + }); + + it('must stringify circular arrays with intermediaries', function() { + var obj = []; + obj.push({name: 'Alice', self: obj}); + obj.push({name: 'Bob', self: obj}); + + assert.deepEqual( + stringify(obj, null, 2), + jsonify([ + {name: 'Alice', self: '[Circular ~]'}, + {name: 'Bob', self: '[Circular ~]'} + ]) + ); + }); + + it('must stringify repeated objects in objects', function() { + var obj = {}; + var alice = {name: 'Alice'}; + obj.alice1 = alice; + obj.alice2 = alice; + + assert.deepEqual( + stringify(obj, null, 2), + jsonify({ + alice1: {name: 'Alice'}, + alice2: {name: 'Alice'} + }) + ); + }); + + it('must stringify repeated objects in arrays', function() { + var alice = {name: 'Alice'}; + var obj = [alice, alice]; + var json = stringify(obj, null, 2); + assert.deepEqual(json, jsonify([{name: 'Alice'}, {name: 'Alice'}])); + }); + + it('must call given decycler and use its output', function() { + var obj = {}; + obj.a = obj; + obj.b = obj; + + var decycle = Sinon.spy(function() { + return decycle.callCount; + }); + var json = stringify(obj, null, 2, decycle); + assert.deepEqual(json, jsonify({a: 1, b: 2}, null, 2)); + + assert.strictEqual(decycle.callCount, 2); + assert.strictEqual(decycle.thisValues[0], obj); + assert.strictEqual(decycle.args[0][0], 'a'); + assert.strictEqual(decycle.args[0][1], obj); + assert.strictEqual(decycle.thisValues[1], obj); + assert.strictEqual(decycle.args[1][0], 'b'); + assert.strictEqual(decycle.args[1][1], obj); + }); + + it('must call replacer and use its output', function() { + var obj = {name: 'Alice', child: {name: 'Bob'}}; + + var replacer = Sinon.spy(bangString); + var json = stringify(obj, replacer, 2); + assert.deepEqual(json, jsonify({name: 'Alice!', child: {name: 'Bob!'}})); + + assert.strictEqual(replacer.callCount, 4); + assert.strictEqual(replacer.args[0][0], ''); + assert.strictEqual(replacer.args[0][1], obj); + assert.strictEqual(replacer.thisValues[1], obj); + assert.strictEqual(replacer.args[1][0], 'name'); + assert.strictEqual(replacer.args[1][1], 'Alice'); + assert.strictEqual(replacer.thisValues[2], obj); + assert.strictEqual(replacer.args[2][0], 'child'); + assert.strictEqual(replacer.args[2][1], obj.child); + assert.strictEqual(replacer.thisValues[3], obj.child); + assert.strictEqual(replacer.args[3][0], 'name'); + assert.strictEqual(replacer.args[3][1], 'Bob'); + }); + + it('must call replacer after describing circular references', function() { + var obj = {name: 'Alice'}; + obj.self = obj; + + var replacer = Sinon.spy(bangString); + var json = stringify(obj, replacer, 2); + assert.deepEqual(json, jsonify({name: 'Alice!', self: '[Circular ~]!'})); + + assert.strictEqual(replacer.callCount, 3); + assert.strictEqual(replacer.args[0][0], ''); + assert.strictEqual(replacer.args[0][1], obj); + assert.strictEqual(replacer.thisValues[1], obj); + assert.strictEqual(replacer.args[1][0], 'name'); + assert.strictEqual(replacer.args[1][1], 'Alice'); + assert.strictEqual(replacer.thisValues[2], obj); + assert.strictEqual(replacer.args[2][0], 'self'); + assert.strictEqual(replacer.args[2][1], '[Circular ~]'); + }); + + it('must call given decycler and use its output for nested objects', function() { + var obj = {}; + obj.a = obj; + obj.b = {self: obj}; + + var decycle = Sinon.spy(function() { + return decycle.callCount; + }); + var json = stringify(obj, null, 2, decycle); + assert.deepEqual(json, jsonify({a: 1, b: {self: 2}})); + + assert.strictEqual(decycle.callCount, 2); + assert.strictEqual(decycle.args[0][0], 'a'); + assert.strictEqual(decycle.args[0][1], obj); + assert.strictEqual(decycle.args[1][0], 'self'); + assert.strictEqual(decycle.args[1][1], obj); + }); + + it("must use decycler's output when it returned null", function() { + var obj = {a: 'b'}; + obj.self = obj; + obj.selves = [obj, obj]; + + function decycle() { + return null; + } + assert.deepEqual( + stringify(obj, null, 2, decycle), + jsonify({ + a: 'b', + self: null, + selves: [null, null] + }) + ); + }); + + it("must use decycler's output when it returned undefined", function() { + var obj = {a: 'b'}; + obj.self = obj; + obj.selves = [obj, obj]; + + function decycle() {} + assert.deepEqual( + stringify(obj, null, 2, decycle), + jsonify({ + a: 'b', + selves: [null, null] + }) + ); + }); + + it('must throw given a decycler that returns a cycle', function() { + var obj = {}; + obj.self = obj; + var err; + function identity(key, value) { + return value; + } + try { + stringify(obj, null, 2, identity); + } catch (ex) { + err = ex; + } + assert.ok(err instanceof TypeError); + }); + + it('must stringify error objects, including extra properties', function() { + var obj = new Error('Wubba Lubba Dub Dub'); + obj.reason = new TypeError("I'm pickle Riiick!"); + + // Testing whole stack is reduntant, we just need to know that it has been stringified + // And that it includes our first frame + var stringified = stringify(obj, null, 2); + assert.equal(obj.stack.indexOf('Error: Wubba Lubba Dub Dub') === 0, true); + assert.equal(obj.reason.stack.indexOf("TypeError: I'm pickle Riiick!") === 0, true); + + // Skip stack, as it has been tested above + delete obj.stack; + delete obj.reason.stack; + stringified = stringify(obj, null, 2); + + assert.deepEqual( + stringified, + jsonify({ + message: 'Wubba Lubba Dub Dub', + name: 'Error', + reason: { + message: "I'm pickle Riiick!", + name: 'TypeError' + } + }) + ); + }); + + it('must stringify error objects with circular references', function() { + var obj = new Error('Wubba Lubba Dub Dub'); + obj.reason = obj; + + // Skip stack, as it has been tested above + delete obj.stack; + var stringified = stringify(obj, null, 2); + + assert.deepEqual( + stringified, + jsonify({ + message: 'Wubba Lubba Dub Dub', + name: 'Error', + reason: '[Circular ~]' + }) + ); + }); + + describe('.getSerialize', function() { + it('must stringify circular objects', function() { + var obj = {a: 'b'}; + obj.circularRef = obj; + obj.list = [obj, obj]; + + var json = JSON.stringify(obj, stringify.getSerialize(), 2); + assert.deepEqual( + json, + jsonify({ + a: 'b', + circularRef: '[Circular ~]', + list: ['[Circular ~]', '[Circular ~]'] + }) + ); + }); + + // This is the behavior as of Mar 3, 2015. + // The serializer function keeps state inside the returned function and + // so far I'm not sure how to not do that. JSON.stringify's replacer is not + // called _after_ serialization. + xit('must return a function that could be called twice', function() { + var obj = {name: 'Alice'}; + obj.self = obj; + + var json; + var serializer = stringify.getSerialize(); + + json = JSON.stringify(obj, serializer, 2); + assert.deepEqual(json, jsonify({name: 'Alice', self: '[Circular ~]'})); + + json = JSON.stringify(obj, serializer, 2); + assert.deepEqual(json, jsonify({name: 'Alice', self: '[Circular ~]'})); + }); + }); +}); + +function bangString(key, value) { + return typeof value === 'string' ? value + '!' : value; +} diff --git a/vendor/json-stringify-safe.js b/vendor/json-stringify-safe.js new file mode 100644 index 0000000..5127c53 --- /dev/null +++ b/vendor/json-stringify-safe.js @@ -0,0 +1,69 @@ +'use strict'; + +/* + json-stringify-safe + Like JSON.stringify, but doesn't throw on circular references. + + Originally forked from https://github.com/isaacs/json-stringify-safe + version 5.0.1 on 2017-09-21 and modified to handle Errors serialization. + Tests for this are in test/vendor. + + ISC license: https://github.com/isaacs/json-stringify-safe/blob/master/LICENSE + */ + +exports = module.exports = stringify; +exports.getSerialize = serializer; + +function stringify(obj, replacer, spaces, cycleReplacer) { + return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces); +} + +// https://github.com/ftlabs/js-abbreviate/blob/fa709e5f139e7770a71827b1893f22418097fbda/index.js#L95-L106 +function stringifyError(value) { + var err = { + // These properties are implemented as magical getters and don't show up in for in + stack: value.stack, + message: value.message, + name: value.name + }; + + for (var i in value) { + if (Object.prototype.hasOwnProperty.call(value, i)) { + err[i] = value[i]; + } + } + + return err; +} + +function serializer(replacer, cycleReplacer) { + var stack = []; + var keys = []; + + if (cycleReplacer == null) { + cycleReplacer = function(key, value) { + if (stack[0] === value) { + return '[Circular ~]'; + } + return '[Circular ~.' + keys.slice(0, stack.indexOf(value)).join('.') + ']'; + }; + } + + return function(key, value) { + if (stack.length > 0) { + var thisPos = stack.indexOf(this); + ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); + ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); + + if (~stack.indexOf(value)) { + value = cycleReplacer.call(this, key, value); + } + } else { + stack.push(value); + } + + return replacer == null + ? value instanceof Error ? stringifyError(value) : value + : replacer.call(this, key, value); + }; +}