express-keenio
Version:
Express middleware for creating events from request-responses.
312 lines (263 loc) • 14 kB
JavaScript
"use strict";
// Grab dependencies required by the middleware.
var util = require('util'),
Keen = require('keen-js'),
// See [connect-friendwares](http://github.com/sebinsua/connect-friendwares).
friendwares = require('connect-friendwares'),
EventEmitter = require('events').EventEmitter;
// See [lib/core/helpers](http://sebinsua.github.io/express-keenio/helpers.html).
var helpers = require('./core/helpers'),
// See [lib/core/options-parser](http://sebinsua.github.io/express-keenio/options-parser.html).
optionsParser = require('./core/options-parser');
// See [core/route-schemas](http://sebinsua.github.io/express-keenio/route-schemas.html).
var RouteSchemas = require('./core/route-schemas');
// See [lib/routes](http://sebinsua.github.io/express-keenio/routes.html).
var RoutesModule = require('./routes'),
// See [lib/parse/proxy-response](http://sebinsua.github.io/express-keenio/proxy-response.html).
ProxyResponseModule = require('./parse/proxy-response'),
// See [lib/parse/request](http://sebinsua.github.io/express-keenio/request.html).
RequestModule = require('./parse/request'),
// See [lib/parse/identify](http://sebinsua.github.io/express-keenio/identify.html).
IdentifyModule = require('./parse/identify'),
// See [lib/event-collection](http://sebinsua.github.io/express-keenio/event-collection.html).
EventCollectionModule = require('./event-collection'),
// See [lib/keen-event](http://sebinsua.github.io/express-keenio/keen-event.html).
KeenEventModule = require('./keen-event');
// Construct the middleware uninitialised with some default events.
function KeenioMiddleware () {
this.options = {};
this.handlers = {};
this.initialized = false;
// This initialises an internal `_ee` EventEmitter with the events: error, info, track and flush.
// Everything is noop'd.
this._ee = helpers.setDefaultEvents(new EventEmitter(), ['error', 'info', 'warning', 'debug', 'track', 'flush']);
}
// Bind the EventEmitter methods to the middleware.
helpers.forEach(EventEmitter.prototype, function (fn, key) {
KeenioMiddleware.prototype[key] = function () {
try {
this._ee[key].apply(this._ee, arguments);
} catch (err) {
var errorMessage = "Error from express-keenio not handled with .on('error'): " + err.stack;
console.error(errorMessage);
}
};
});
// Before the middleware can be used it must have its configuration passed into it.
KeenioMiddleware.prototype.configure = function (options) {
this.options = optionsParser.parse(options);
this._configureHandlers(this.options);
this.routeSchemas = new RouteSchemas(this.options);
this.keenClient = new Keen(options.client);
this.initialized = true;
this.emit('initialized');
// Returning `this` makes it possible to `require()` and `configure()` the middleware in one statement.
return this;
};
// A method may be called to check whether the middleware has had `configure()` called before
// it is used. It will throw an exception if the middleware has not yet been initialised.
KeenioMiddleware.prototype.checkInitialized = function () {
if (!this.initialized) {
throw new Error('express-keenio middleware must be configured before use. Please call ' +
'keenio-middleware#configure(options).');
}
return true;
};
// This method allows us to check that the middleware is in use by Express and not
// just Connect. It does this by checking for the existence of a middleware called
// 'expressInit'.
//
// The purpose of this is that the middleware requires some behaviours given by Express,
// particularly relating to route definitions.
KeenioMiddleware.prototype.isMiddlewareUsedByExpress = function (app) {
return friendwares(app).has('expressInit');
};
// There is no good reason to send an event to Keen.IO if it's for an invalid response.
// The default is: if the app wouldn't handle a request, the middleware shouldn't handle it either.
// This behaviour can be switched off by modifying the config option `httpErrorsRecorded` to be true.
KeenioMiddleware.prototype.isAcceptableStatusCode = function (statusCode) {
var whitelistCodes = [401, 402, 404],
firstCharacter = String(statusCode).charAt(0);
return firstCharacter !== '5' && firstCharacter !== '4' || whitelistCodes.indexOf(statusCode) !== -1;
};
// This is exposed in order that an instance of the middleware can be passed directly into
// `app.use()` - which either executes a function, or looks for the function at an object's
// `handle` property (not supported from Express 4.0 onwards.)
KeenioMiddleware.prototype.handle = function keenioHandler(req, res, next) {
var middleware = this.handleAll();
middleware(req, res, next);
};
KeenioMiddleware.prototype.handleAll = KeenioMiddleware.prototype.track = function () {
this.checkInitialized();
return this._generateHandler({});
};
// `trackRoute()` allows the middlewares to be attached to specific [Express.js](http://expressjs.com)
// routes and to explicitly define their event collection name.
// All of its arguments are optional but all of them are beneficial.
KeenioMiddleware.prototype.trackRoute = function (eventCollectionName, whitelistProperties, eventTag) {
this.checkInitialized();
return this._generateHandler({
eventCollectionName: eventCollectionName,
whitelistProperties: whitelistProperties || {},
tag: eventTag
});
};
// This is an internal method which generates the middleware handler when given a routeConfig.
// The routeConfig must be empty if we are using the middleware against the whole app.
KeenioMiddleware.prototype._generateHandler = function (routeConfig) {
var self = this;
return function keenioHandler(req, res, next) {
// We ensure that the middleware is being used by an express app.
if (!req.app || !self.isMiddlewareUsedByExpress(req.app)) {
self.emit('error', 'Currently this middleware is only supported by Express.js.');
return next();
}
// We swap the methods on a response object with proxied versions, and respond
// with a parsedResponseData object that is still in scope of the res object's proxied
// response methods (closures.)
// This may not be the most elegant solution but it works.
var parsedResponseData = self.proxyResponseHandler.proxyResponseObject(res);
// We setup a function that will be called when a response is finished, and pass
// in the request object, routeConfig, and parsedResponseData object.
// The parsedResponseData object at this point contains no data, but since it is
// still in the scope of the proxied response object it will contain response data
// by the time the response has had its `finish` event emitted.
res.on("finish", self._finishResponse(req, parsedResponseData, routeConfig));
return next();
};
};
// This generates a closure that has access to req, _parsedResponseData and routeConfig.
// It should be called by Express when the 'finish' event is emitted (see above).
KeenioMiddleware.prototype._finishResponse = function (req, _parsedResponseData, routeConfig) {
var self = this;
return function onResponseFinish() {
// It's only at the response 'finish' event that we can be sure that req.route is set.
// This is due to express populating it in the app.router middleware.
if (!req.route) {
return false; // If a non-route middleware has been run this would be the case. E.g. favicon.ico
}
// If the response was a 5XX error or some kind of malformed request
// *by default* we shouldn't make a [Keen.IO](http://keen.io) event.
// The assumption underlying this is that you want to analyse how people are using the service
// and not how the service is failing, or how attackers are trying to compromise it.
if (self.options.httpErrorsRecorded === false &&
!self.isAcceptableStatusCode(_parsedResponseData.status)) {
return false;
}
// If a route is excluded in the configuration we end the middleware having made no requests
// to Keen.IO.
if (self.options.hasOwnProperty('excludeRoutes')) {
if (self.routesHandler.isExcludedRoute(req.route)) {
return false;
}
}
// The config is created from multiple configs, which override each other in an order specified by `_overrideConfig`.
var isWhitelistExplicitlyDefined = false, config = self.options;
try {
var _temp = self._overrideConfig(req.route, routeConfig);
isWhitelistExplicitlyDefined = _temp.isWhitelistExplicitlyDefined;
config = _temp.config;
} catch (err) {
// The config has a routes key, but this route was not defined inside it. Yugh.
return false;
}
var identity = self.identifyHandler.identify(req),
parsedRequestData = self.requestHandler.parseRequestObject(req),
parsedResponseData = _parsedResponseData;
var eventCollection, keenEvent;
// We test to see if an event collection name is defined by any configuration and that it is valid.
// If it is not valid then we attempt to generate it from the route.
if (config.eventCollectionName) {
if (self.eventCollectionHandler._isValidEventCollectionName(config.eventCollectionName)) {
eventCollection = config.eventCollectionName;
} else {
self.emit('error', 'ERROR: ' + config.eventCollectionName + ' is not a valid event collection name so another has been automatically generated.');
eventCollection = self.eventCollectionHandler.generateName(req.route);
}
} else {
eventCollection = self.eventCollectionHandler.generateName(req.route);
}
try {
keenEvent = self.keenEventHandler.generateEvent(identity, parsedRequestData, parsedResponseData, config);
// If the whitelist was defined explicitly, then we won't be trying to create a whitelist in the background.
if (!isWhitelistExplicitlyDefined) {
self.routeSchemas.add(req.route, keenEvent);
}
self._trackEvent(eventCollection, keenEvent);
} catch (e) {
// If there are any errors do not send the event to Keen.IO.
self.emit("error", e);
}
};
};
// Given an event collection name and a Keen.IO event we send out a track event
// and make a request to Keen.IO.
KeenioMiddleware.prototype._trackEvent = function (eventCollection, keenEvent) {
var keenioMiddlewareScope = this;
this.emit("track", { eventCollection: eventCollection, keenEvent: keenEvent });
this.keenClient.addEvent(eventCollection, keenEvent, function (err, res) {
if (err) {
// This extra data should allow us to see if there is anything
// bad about the data that was sent to keen-js that might have
// caused the request to fail.
err.collection = eventCollection;
err.event = keenEvent;
return keenioMiddlewareScope.emit("error", err);
}
if (res.created) {
keenioMiddlewareScope.emit("info", "Keen.IO event created.");
} else {
keenioMiddlewareScope.emit("info", "Keen.IO event not created.");
}
});
};
KeenioMiddleware.prototype._overrideConfig = function (route, routeConfig) {
var config = {};
// We *DO NOT* `getWhitelist()` or `routeSchema.add()` if an explicit whitelist was set.
var isWhitelistExplicitlyDefined = false;
if ( (this.options.whitelistProperties && Object.keys(this.options.whitelistProperties).length) ||
(routeConfig.whitelistProperties && Object.keys(routeConfig.whitelistProperties).length)) {
isWhitelistExplicitlyDefined = true;
}
var _configs = [];
// If a route has data stored in the 'routes' key of the configuration, we fetch this data.
var _hasRouteConfig = this.options.hasOwnProperty('routes');
if (_hasRouteConfig) {
var eventCollectionMetadata = this.routesHandler.getRouteConfig(route);
// If a route has no config but the configuration does have a routes key then consider it excluded.
if (!eventCollectionMetadata) {
throw new Error("Config has a routes key, but this route is not defined.");
}
// If it was set in the event collections metadata, then we reset the is whitelist explicitly defined value.
isWhitelistExplicitlyDefined = isWhitelistExplicitlyDefined || eventCollectionMetadata.whitelistProperties;
// `routeConfig.*` should override the `eventCollectiomMetadata.*` which should override the `options.*`
// and the `_whitelistPropertiesConfig` which is a fallback set later on if the whitelist was not explicitly defined.
// (This is about whitelists, but `eventCollectionName` and `tag` are also overridden in this order.)
_configs = [eventCollectionMetadata, routeConfig];
} else {
_configs = [routeConfig];
}
var _optionsWhitelist = this.options.whitelistProperties, _preConfig;
if (isWhitelistExplicitlyDefined) {
_preConfig = [{}, _optionsWhitelist];
_configs = _preConfig.concat.apply(_preConfig, _configs);
} else {
var _whitelistPropertiesConfig = { whitelistProperties: this.routeSchemas.getWhitelist(route) };
_preConfig = [{}, _whitelistPropertiesConfig, _optionsWhitelist];
_configs = _preConfig.concat.apply(_preConfig, _configs);
}
config = helpers.extend.apply(helpers, _configs);
return {
config: config,
isWhitelistExplicitlyDefined: isWhitelistExplicitlyDefined
};
};
KeenioMiddleware.prototype._configureHandlers = function (options) {
this.routesHandler = new RoutesModule(options, this._ee);
this.proxyResponseHandler = new ProxyResponseModule(options, this._ee);
this.requestHandler = new RequestModule(options, this._ee);
this.identifyHandler = new IdentifyModule(options, this._ee);
this.eventCollectionHandler = new EventCollectionModule(options, this._ee);
this.keenEventHandler = new KeenEventModule(options, this._ee);
};
exports = module.exports = new KeenioMiddleware();