diff --git a/lib/StripeResource.js b/lib/StripeResource.js index ebcec4e035..b2bddbac53 100644 --- a/lib/StripeResource.js +++ b/lib/StripeResource.js @@ -16,6 +16,8 @@ StripeResource.extend = utils.protoExtend; StripeResource.method = require('./StripeMethod'); StripeResource.BASIC_METHODS = require('./StripeMethod.basic'); +StripeResource.MAX_BUFFERED_REQUEST_METRICS = 100; + /** * Encapsulates request logic for a Stripe Resource */ @@ -125,6 +127,8 @@ StripeResource.prototype = { // lastResponse. res.requestId = headers['request-id']; + var requestDurationMs = Date.now() - req._requestStart; + var responseEvent = utils.removeEmpty({ api_version: headers['stripe-version'], account: headers['stripe-account'], @@ -133,7 +137,7 @@ StripeResource.prototype = { path: req._requestEvent.path, status: res.statusCode, request_id: res.requestId, - elapsed: Date.now() - req._requestStart, + elapsed: requestDurationMs, }); self._stripe._emitter.emit('response', responseEvent); @@ -171,6 +175,9 @@ StripeResource.prototype = { null ); } + + self._recordRequestMetrics(res.requestId, requestDurationMs); + // Expose res object Object.defineProperty(response, 'lastResponse', { enumerable: false, @@ -225,6 +232,28 @@ StripeResource.prototype = { return headers; }, + _addTelemetryHeader: function(headers) { + if (this._stripe.getTelemetryEnabled() && this._stripe._prevRequestMetrics.length > 0) { + var metrics = this._stripe._prevRequestMetrics.shift(); + headers['X-Stripe-Client-Telemetry'] = JSON.stringify({ + 'last_request_metrics': metrics + }); + } + }, + + _recordRequestMetrics: function(requestId, requestDurationMs) { + if (this._stripe.getTelemetryEnabled() && requestId) { + if (this._stripe._prevRequestMetrics.length > StripeResource.MAX_BUFFERED_REQUEST_METRICS) { + utils.emitWarning('Request metrics buffer is full, dropping telemetry message.'); + } else { + this._stripe._prevRequestMetrics.push({ + 'request_id': requestId, + 'request_duration_ms': requestDurationMs, + }); + } + } + }, + _request: function(method, host, path, data, auth, options, callback) { var self = this; var requestData; @@ -248,6 +277,8 @@ StripeResource.prototype = { Object.assign(headers, options.headers); } + self._addTelemetryHeader(headers); + makeRequest(apiVersion, headers); }); } diff --git a/lib/stripe.js b/lib/stripe.js index 2569e69e6c..8943a6ae53 100644 --- a/lib/stripe.js +++ b/lib/stripe.js @@ -150,6 +150,9 @@ function Stripe(key, version) { this.errors = require('./Error'); this.webhooks = require('./Webhooks'); + + this._prevRequestMetrics = []; + this.setTelemetryEnabled(false); } Stripe.errors = require('./Error'); @@ -299,12 +302,19 @@ Stripe.prototype = { return formatted; }, + setTelemetryEnabled: function(enableTelemetry) { + this._enableTelemetry = enableTelemetry; + }, + + getTelemetryEnabled: function() { + return this._enableTelemetry; + }, + _prepResources: function() { for (var name in resources) { this[utils.pascalToCamelCase(name)] = new resources[name](this); } }, - }; module.exports = Stripe; diff --git a/lib/utils.js b/lib/utils.js index 0750965130..0c7b7766ae 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -252,6 +252,8 @@ var utils = module.exports = { return name[0].toLowerCase() + name.substring(1); } }, + + emitWarning: emitWarning, }; function emitWarning(warning) { diff --git a/test/telemetry.spec.js b/test/telemetry.spec.js new file mode 100644 index 0000000000..845d51770a --- /dev/null +++ b/test/telemetry.spec.js @@ -0,0 +1,151 @@ +'use strict'; + +require('../testUtils'); +var http = require('http'); + +var expect = require('chai').expect; +var testServer = null; + +function createTestServer(handlerFunc, cb) { + var host = '127.0.0.1'; + testServer = http.createServer(function(req, res) { + try { + handlerFunc(req, res); + } catch (e) { + res.writeHead(400, {'Content-Type': 'application/json'}); + res.end(JSON.stringify({ + error: {type: 'invalid_request_error', message: e.message} + })); + } + }); + testServer.listen(0, host, function() { + var port = testServer.address().port; + cb(host, port); + }); +} + +describe('Client Telemetry', function() { + afterEach(function() { + if (testServer) { + testServer.close(); + testServer = null; + } + }); + + it('Does not send telemetry when disabled', function(done) { + var numRequests = 0; + + createTestServer(function (req, res) { + numRequests += 1; + + var telemetry = req.headers['x-stripe-client-telemetry']; + + switch (numRequests) { + case 1: + case 2: + expect(telemetry).to.not.exist; + break; + default: + expect.fail(`Should not have reached request ${numRequests}`); + } + + res.setHeader('Request-Id', `req_${numRequests}`); + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }, function(host, port) { + const stripe = require('../lib/stripe')('sk_test_FEiILxKZwnmmocJDUjUNO6pa') + stripe.setHost(host, port, 'http'); + + stripe.balance.retrieve().then(function (res) { + return stripe.balance.retrieve(); + }).then(function (res) { + expect(numRequests).to.equal(2); + done(); + }).catch(done); + }); + }); + + it('Sends client telemetry on the second request when enabled', function(done) { + var numRequests = 0; + + createTestServer(function (req, res) { + numRequests += 1; + + var telemetry = req.headers['x-stripe-client-telemetry']; + + switch (numRequests) { + case 1: + expect(telemetry).to.not.exist; + break; + case 2: + expect(telemetry).to.exist; + expect(JSON.parse(telemetry).last_request_metrics.request_id) + .to.equal('req_1'); + break; + default: + expect.fail(`Should not have reached request ${numRequests}`); + } + + res.setHeader('Request-Id', `req_${numRequests}`); + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }, function(host, port) { + const stripe = require('../lib/stripe')('sk_test_FEiILxKZwnmmocJDUjUNO6pa') + stripe.setTelemetryEnabled(true); + stripe.setHost(host, port, 'http'); + + stripe.balance.retrieve().then(function (res) { + return stripe.balance.retrieve(); + }).then(function (res) { + expect(numRequests).to.equal(2); + done(); + }).catch(done); + }); + }); + + it('Buffers metrics on concurrent requests', function(done) { + var numRequests = 0; + + createTestServer(function (req, res) { + numRequests += 1; + + var telemetry = req.headers['x-stripe-client-telemetry']; + + switch (numRequests) { + case 1: + case 2: + expect(telemetry).to.not.exist; + break; + case 3: + case 4: + expect(telemetry).to.exist; + expect(JSON.parse(telemetry).last_request_metrics.request_id) + .to.be.oneOf(['req_1', 'req_2']); + break; + default: + expect.fail(`Should not have reached request ${numRequests}`); + } + + res.setHeader('Request-Id', `req_${numRequests}`); + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end('{}'); + }, function(host, port) { + const stripe = require('../lib/stripe')('sk_test_FEiILxKZwnmmocJDUjUNO6pa') + stripe.setTelemetryEnabled(true); + stripe.setHost(host, port, 'http'); + + Promise.all([ + stripe.balance.retrieve(), + stripe.balance.retrieve() + ]).then(function() { + return Promise.all([ + stripe.balance.retrieve(), + stripe.balance.retrieve() + ]); + }).then(function() { + expect(numRequests).to.equal(4); + done(); + }).catch(done); + }); + }); +});