mixpanel
Version:
A simple server-side API for mixpanel
539 lines (473 loc) • 16.8 kB
JavaScript
/*
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 {
LocalFeatureFlagsProvider,
RemoteFeatureFlagsProvider,
} = require("./flags");
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,
};
const 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) {
let data = "";
res.on("data", function (chunk) {
data += chunk;
});
res.on("end", function () {
let e;
if (metrics.config.verbose) {
try {
const 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 " + ex.message,
);
}
} 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;
const 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}
*/
const chunk = function (arr, size) {
let 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
*
*/
const send_batch_requests = function (options, callback) {
const 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) {
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) {
const 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 = {};
}
const 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) {
let 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) {
const 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);
}
// Initialize feature flags providers if configs are provided
if (config && config.local_flags_config) {
metrics.local_flags = new LocalFeatureFlagsProvider(
token,
config.local_flags_config,
metrics.track.bind(metrics),
config.logger,
);
}
if (config && config.remote_flags_config) {
metrics.remote_flags = new RemoteFeatureFlagsProvider(
token,
config.remote_flags_config,
metrics.track.bind(metrics),
config.logger,
);
}
return metrics;
};
// module exporting
module.exports = {
init: create_client,
};