UNPKG

mixpanel

Version:

A simple server-side API for mixpanel

475 lines (409 loc) 17.2 kB
/* Heavily inspired by the original js library copyright Mixpanel, Inc. (http://mixpanel.com/) Copyright (c) 2012 Carl Sverre Released under the MIT license. */ const querystring = require('querystring'); const Buffer = require('buffer').Buffer; const http = require('http'); const https = require('https'); const HttpsProxyAgent = require('https-proxy-agent'); const url = require('url'); const packageInfo = require('../package.json') const {async_all, ensure_timestamp, assert_logger} = require('./utils'); const {MixpanelGroups} = require('./groups'); const {MixpanelPeople} = require('./people'); const DEFAULT_CONFIG = { test: false, debug: false, verbose: false, host: 'api.mixpanel.com', protocol: 'https', path: '', keepAlive: true, // set this to true to automatically geolocate based on the client's ip. // e.g., when running under electron geolocate: false, logger: console, }; var create_client = function(token, config) { if (!token) { throw new Error("The Mixpanel Client needs a Mixpanel token: `init(token)`"); } const metrics = { token, config: {...DEFAULT_CONFIG}, }; const {keepAlive} = metrics.config; // mixpanel constants const MAX_BATCH_SIZE = 50; const REQUEST_LIBS = {http, https}; const REQUEST_AGENTS = { http: new http.Agent({keepAlive}), https: new https.Agent({keepAlive}), }; const proxyPath = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; const proxyAgent = proxyPath ? new HttpsProxyAgent(Object.assign(url.parse(proxyPath), { keepAlive, })) : null; /** * sends an async GET or POST request to mixpanel * for batch processes data must be send in the body of a POST * @param {object} options * @param {string} options.endpoint * @param {object} options.data the data to send in the request * @param {string} [options.method] e.g. `get` or `post`, defaults to `get` * @param {function} callback called on request completion or error */ metrics.send_request = function(options, callback) { callback = callback || function() {}; let content = Buffer.from(JSON.stringify(options.data)).toString('base64'); const endpoint = options.endpoint; const method = (options.method || 'GET').toUpperCase(); let query_params = { 'ip': metrics.config.geolocate ? 1 : 0, 'verbose': metrics.config.verbose ? 1 : 0 }; const key = metrics.config.key; const secret = metrics.config.secret; const request_lib = REQUEST_LIBS[metrics.config.protocol]; let request_options = { host: metrics.config.host, port: metrics.config.port, headers: {}, method: method }; let request; if (!request_lib) { throw new Error( "Mixpanel Initialization Error: Unsupported protocol " + metrics.config.protocol + ". " + "Supported protocols are: " + Object.keys(REQUEST_LIBS) ); } if (method === 'POST') { content = 'data=' + content; request_options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; request_options.headers['Content-Length'] = Buffer.byteLength(content); } else if (method === 'GET') { query_params.data = content; } // add auth params if (secret) { if (request_lib !== https) { throw new Error("Must use HTTPS if authenticating with API Secret"); } const encoded = Buffer.from(secret + ':').toString('base64'); request_options.headers['Authorization'] = 'Basic ' + encoded; } else if (key) { query_params.api_key = key; } else if (endpoint === '/import') { throw new Error("The Mixpanel Client needs a Mixpanel API Secret when importing old events: `init(token, { secret: ... })`"); } request_options.agent = proxyAgent || REQUEST_AGENTS[metrics.config.protocol]; if (metrics.config.test) { query_params.test = 1; } request_options.path = metrics.config.path + endpoint + "?" + querystring.stringify(query_params); request = request_lib.request(request_options, function(res) { var data = ""; res.on('data', function(chunk) { data += chunk; }); res.on('end', function() { var e; if (metrics.config.verbose) { try { var result = JSON.parse(data); if(result.status != 1) { e = new Error("Mixpanel Server Error: " + result.error); } } catch(ex) { e = new Error("Could not parse response from Mixpanel"); } } else { e = (data !== '1') ? new Error("Mixpanel Server Error: " + data) : undefined; } callback(e); }); }); request.on('error', function(e) { if (metrics.config.debug) { metrics.config.logger.error("Got Error: " + e.message); } callback(e); }); if (method === 'POST') { request.write(content); } request.end(); }; /** * Send an event to Mixpanel, using the specified endpoint (e.g., track/import) * @param {string} endpoint - API endpoint name * @param {string} event - event name * @param {object} properties - event properties * @param {Function} [callback] - callback for request completion/error */ metrics.send_event_request = function(endpoint, event, properties, callback) { properties.token = metrics.token; properties.mp_lib = "node"; properties.$lib_version = packageInfo.version; var data = { event: event, properties: properties }; if (metrics.config.debug) { metrics.config.logger.debug("Sending the following event to Mixpanel", { data }); } metrics.send_request({ method: "GET", endpoint: endpoint, data: data }, callback); }; /** * breaks array into equal-sized chunks, with the last chunk being the remainder * @param {Array} arr * @param {number} size * @returns {Array} */ var chunk = function(arr, size) { var chunks = [], i = 0, total = arr.length; while (i < total) { chunks.push(arr.slice(i, i += size)); } return chunks; }; /** * sends events in batches * @param {object} options * @param {[{}]} options.event_list array of event objects * @param {string} options.endpoint e.g. `/track` or `/import` * @param {number} [options.max_concurrent_requests] limits concurrent async requests over the network * @param {number} [options.max_batch_size] limits number of events sent to mixpanel per request * @param {Function} [callback] callback receives array of errors if any * */ var send_batch_requests = function(options, callback) { var event_list = options.event_list, endpoint = options.endpoint, max_batch_size = options.max_batch_size ? Math.min(MAX_BATCH_SIZE, options.max_batch_size) : MAX_BATCH_SIZE, // to maintain original intention of max_batch_size; if max_batch_size is greater than 50, we assume the user is trying to set max_concurrent_requests max_concurrent_requests = options.max_concurrent_requests || (options.max_batch_size > MAX_BATCH_SIZE && Math.ceil(options.max_batch_size / MAX_BATCH_SIZE)), event_batches = chunk(event_list, max_batch_size), request_batches = max_concurrent_requests ? chunk(event_batches, max_concurrent_requests) : [event_batches], total_event_batches = event_batches.length, total_request_batches = request_batches.length; /** * sends a batch of events to mixpanel through http api * @param {Array} batch * @param {Function} cb */ function send_event_batch(batch, cb) { if (batch.length > 0) { batch = batch.map(function (event) { var properties = event.properties; if (endpoint === '/import' || event.properties.time) { // usually there will be a time property, but not required for `/track` endpoint event.properties.time = ensure_timestamp(event.properties.time); } event.properties.token = event.properties.token || metrics.token; return event; }); // must be a POST metrics.send_request({ method: "POST", endpoint: endpoint, data: batch }, cb); } } /** * Asynchronously sends batches of requests * @param {number} index */ function send_next_request_batch(index) { var request_batch = request_batches[index], cb = function (errors, results) { index += 1; if (index === total_request_batches) { callback && callback(errors, results); } else { send_next_request_batch(index); } }; async_all(request_batch, send_event_batch, cb); } // init recursive function send_next_request_batch(0); if (metrics.config.debug) { metrics.config.logger.debug( "Sending " + event_list.length + " events to Mixpanel in " + total_event_batches + " batches of events and " + total_request_batches + " batches of requests" ); } }; /** track(event, properties, callback) --- this function sends an event to mixpanel. event:string the event name properties:object additional event properties to send callback:function(err:Error) callback is called when the request is finished or an error occurs */ metrics.track = function(event, properties, callback) { if (!properties || typeof properties === "function") { callback = properties; properties = {}; } // time is optional for `track` if (properties.time) { properties.time = ensure_timestamp(properties.time); } metrics.send_event_request("/track", event, properties, callback); }; /** * send a batch of events to mixpanel `track` endpoint: this should only be used if events are less than 5 days old * @param {Array} event_list array of event objects to track * @param {object} [options] * @param {number} [options.max_concurrent_requests] number of concurrent http requests that can be made to mixpanel * @param {number} [options.max_batch_size] number of events that can be sent to mixpanel per request * @param {Function} [callback] callback receives array of errors if any */ metrics.track_batch = function(event_list, options, callback) { options = options || {}; if (typeof options === 'function') { callback = options; options = {}; } var batch_options = { event_list: event_list, endpoint: "/track", max_concurrent_requests: options.max_concurrent_requests, max_batch_size: options.max_batch_size }; send_batch_requests(batch_options, callback); }; /** import(event, time, properties, callback) --- This function sends an event to mixpanel using the import endpoint. The time argument should be either a Date or Number, and should signify the time the event occurred. It is highly recommended that you specify the distinct_id property for each event you import, otherwise the events will be tied to the IP address of the sending machine. For more information look at: https://mixpanel.com/docs/api-documentation/importing-events-older-than-31-days event:string the event name time:date|number the time of the event properties:object additional event properties to send callback:function(err:Error) callback is called when the request is finished or an error occurs */ metrics.import = function(event, time, properties, callback) { if (!properties || typeof properties === "function") { callback = properties; properties = {}; } properties.time = ensure_timestamp(time); metrics.send_event_request("/import", event, properties, callback); }; /** import_batch(event_list, options, callback) --- This function sends a list of events to mixpanel using the import endpoint. The format of the event array should be: [ { "event": "event name", "properties": { "time": new Date(), // Number or Date; required for each event "key": "val", ... } }, { "event": "event name", "properties": { "time": new Date() // Number or Date; required for each event } }, ... ] See import() for further information about the import endpoint. Options: max_batch_size: the maximum number of events to be transmitted over the network simultaneously. useful for capping bandwidth usage. max_concurrent_requests: the maximum number of concurrent http requests that can be made to mixpanel; also useful for capping bandwidth. N.B.: the Mixpanel API only accepts 50 events per request, so regardless of max_batch_size, larger lists of events will be chunked further into groups of 50. event_list:array list of event names and properties options:object optional batch configuration callback:function(error_list:array) callback is called when the request is finished or an error occurs */ metrics.import_batch = function(event_list, options, callback) { var batch_options; if (typeof(options) === "function" || !options) { callback = options; options = {}; } batch_options = { event_list: event_list, endpoint: "/import", max_concurrent_requests: options.max_concurrent_requests, max_batch_size: options.max_batch_size }; send_batch_requests(batch_options, callback); }; /** alias(distinct_id, alias) --- This function creates an alias for distinct_id For more information look at: https://mixpanel.com/docs/integration-libraries/using-mixpanel-alias distinct_id:string the current identifier alias:string the future alias */ metrics.alias = function(distinct_id, alias, callback) { var properties = { distinct_id: distinct_id, alias: alias }; metrics.track('$create_alias', properties, callback); }; metrics.groups = new MixpanelGroups(metrics); metrics.people = new MixpanelPeople(metrics); /** set_config(config) --- Modifies the mixpanel config config:object an object with properties to override in the mixpanel client config */ metrics.set_config = function(config) { if (config && config.logger !== undefined) { assert_logger(config.logger); } Object.assign(metrics.config, config); if (config.host) { // Split host into host and port const [host, port] = config.host.split(':'); metrics.config.host = host; if (port) { metrics.config.port = Number(port); } } }; if (config) { metrics.set_config(config); } return metrics; }; // module exporting module.exports = { init: create_client, };