UNPKG

17.2 kBJavaScriptView Raw
1/*
2 Heavily inspired by the original js library copyright Mixpanel, Inc.
3 (http://mixpanel.com/)
4
5 Copyright (c) 2012 Carl Sverre
6
7 Released under the MIT license.
8*/
9
10const querystring = require('querystring');
11const Buffer = require('buffer').Buffer;
12const http = require('http');
13const https = require('https');
14const HttpsProxyAgent = require('https-proxy-agent');
15const url = require('url');
16const packageInfo = require('../package.json')
17
18const {async_all, ensure_timestamp} = require('./utils');
19const {MixpanelGroups} = require('./groups');
20const {MixpanelPeople} = require('./people');
21
22const DEFAULT_CONFIG = {
23 test: false,
24 debug: false,
25 verbose: false,
26 host: 'api.mixpanel.com',
27 protocol: 'https',
28 path: '',
29 keepAlive: true,
30 // set this to true to automatically geolocate based on the client's ip.
31 // e.g., when running under electron
32 geolocate: false,
33};
34
35var create_client = function(token, config) {
36 if (!token) {
37 throw new Error("The Mixpanel Client needs a Mixpanel token: `init(token)`");
38 }
39
40 const metrics = {
41 token,
42 config: {...DEFAULT_CONFIG},
43 };
44 const {keepAlive} = metrics.config;
45
46 // mixpanel constants
47 const MAX_BATCH_SIZE = 50;
48 const REQUEST_LIBS = {http, https};
49 const REQUEST_AGENTS = {
50 http: new http.Agent({keepAlive}),
51 https: new https.Agent({keepAlive}),
52 };
53 const proxyPath = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
54 const proxyAgent = proxyPath ? new HttpsProxyAgent(Object.assign(url.parse(proxyPath), {
55 keepAlive,
56 })) : null;
57
58 /**
59 * sends an async GET or POST request to mixpanel
60 * for batch processes data must be send in the body of a POST
61 * @param {object} options
62 * @param {string} options.endpoint
63 * @param {object} options.data the data to send in the request
64 * @param {string} [options.method] e.g. `get` or `post`, defaults to `get`
65 * @param {function} callback called on request completion or error
66 */
67 metrics.send_request = function(options, callback) {
68 callback = callback || function() {};
69
70 let content = Buffer.from(JSON.stringify(options.data)).toString('base64');
71 const endpoint = options.endpoint;
72 const method = (options.method || 'GET').toUpperCase();
73 let query_params = {
74 'ip': metrics.config.geolocate ? 1 : 0,
75 'verbose': metrics.config.verbose ? 1 : 0
76 };
77 const key = metrics.config.key;
78 const secret = metrics.config.secret;
79 const request_lib = REQUEST_LIBS[metrics.config.protocol];
80 let request_options = {
81 host: metrics.config.host,
82 port: metrics.config.port,
83 headers: {},
84 method: method
85 };
86 let request;
87
88 if (!request_lib) {
89 throw new Error(
90 "Mixpanel Initialization Error: Unsupported protocol " + metrics.config.protocol + ". " +
91 "Supported protocols are: " + Object.keys(REQUEST_LIBS)
92 );
93 }
94
95
96 if (method === 'POST') {
97 content = 'data=' + content;
98 request_options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
99 request_options.headers['Content-Length'] = Buffer.byteLength(content);
100 } else if (method === 'GET') {
101 query_params.data = content;
102 }
103
104
105 // add auth params
106 if (secret) {
107 if (request_lib !== https) {
108 throw new Error("Must use HTTPS if authenticating with API Secret");
109 }
110 const encoded = Buffer.from(secret + ':').toString('base64');
111 request_options.headers['Authorization'] = 'Basic ' + encoded;
112 } else if (key) {
113 query_params.api_key = key;
114 } else if (endpoint === '/import') {
115 throw new Error("The Mixpanel Client needs a Mixpanel API Secret when importing old events: `init(token, { secret: ... })`");
116 }
117
118 request_options.agent = proxyAgent || REQUEST_AGENTS[metrics.config.protocol];
119
120 if (metrics.config.test) {
121 query_params.test = 1;
122 }
123
124 request_options.path = metrics.config.path + endpoint + "?" + querystring.stringify(query_params);
125
126 request = request_lib.request(request_options, function(res) {
127 var data = "";
128 res.on('data', function(chunk) {
129 data += chunk;
130 });
131
132 res.on('end', function() {
133 var e;
134 if (metrics.config.verbose) {
135 try {
136 var result = JSON.parse(data);
137 if(result.status != 1) {
138 e = new Error("Mixpanel Server Error: " + result.error);
139 }
140 }
141 catch(ex) {
142 e = new Error("Could not parse response from Mixpanel");
143 }
144 }
145 else {
146 e = (data !== '1') ? new Error("Mixpanel Server Error: " + data) : undefined;
147 }
148
149 callback(e);
150 });
151 });
152
153 request.on('error', function(e) {
154 if (metrics.config.debug) {
155 console.log("Got Error: " + e.message);
156 }
157 callback(e);
158 });
159
160 if (method === 'POST') {
161 request.write(content);
162 }
163 request.end();
164 };
165
166 /**
167 * Send an event to Mixpanel, using the specified endpoint (e.g., track/import)
168 * @param {string} endpoint - API endpoint name
169 * @param {string} event - event name
170 * @param {object} properties - event properties
171 * @param {Function} [callback] - callback for request completion/error
172 */
173 metrics.send_event_request = function(endpoint, event, properties, callback) {
174 properties.token = metrics.token;
175 properties.mp_lib = "node";
176 properties.$lib_version = packageInfo.version;
177
178 var data = {
179 event: event,
180 properties: properties
181 };
182
183 if (metrics.config.debug) {
184 console.log("Sending the following event to Mixpanel:\n", data);
185 }
186
187 metrics.send_request({ method: "GET", endpoint: endpoint, data: data }, callback);
188 };
189
190 /**
191 * breaks array into equal-sized chunks, with the last chunk being the remainder
192 * @param {Array} arr
193 * @param {number} size
194 * @returns {Array}
195 */
196 var chunk = function(arr, size) {
197 var chunks = [],
198 i = 0,
199 total = arr.length;
200
201 while (i < total) {
202 chunks.push(arr.slice(i, i += size));
203 }
204 return chunks;
205 };
206
207 /**
208 * sends events in batches
209 * @param {object} options
210 * @param {[{}]} options.event_list array of event objects
211 * @param {string} options.endpoint e.g. `/track` or `/import`
212 * @param {number} [options.max_concurrent_requests] limits concurrent async requests over the network
213 * @param {number} [options.max_batch_size] limits number of events sent to mixpanel per request
214 * @param {Function} [callback] callback receives array of errors if any
215 *
216 */
217 var send_batch_requests = function(options, callback) {
218 var event_list = options.event_list,
219 endpoint = options.endpoint,
220 max_batch_size = options.max_batch_size ? Math.min(MAX_BATCH_SIZE, options.max_batch_size) : MAX_BATCH_SIZE,
221 // 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
222 max_concurrent_requests = options.max_concurrent_requests || (options.max_batch_size > MAX_BATCH_SIZE && Math.ceil(options.max_batch_size / MAX_BATCH_SIZE)),
223 event_batches = chunk(event_list, max_batch_size),
224 request_batches = max_concurrent_requests ? chunk(event_batches, max_concurrent_requests) : [event_batches],
225 total_event_batches = event_batches.length,
226 total_request_batches = request_batches.length;
227
228 /**
229 * sends a batch of events to mixpanel through http api
230 * @param {Array} batch
231 * @param {Function} cb
232 */
233 function send_event_batch(batch, cb) {
234 if (batch.length > 0) {
235 batch = batch.map(function (event) {
236 var properties = event.properties;
237 if (endpoint === '/import' || event.properties.time) {
238 // usually there will be a time property, but not required for `/track` endpoint
239 event.properties.time = ensure_timestamp(event.properties.time);
240 }
241 event.properties.token = event.properties.token || metrics.token;
242 return event;
243 });
244
245 // must be a POST
246 metrics.send_request({ method: "POST", endpoint: endpoint, data: batch }, cb);
247 }
248 }
249
250 /**
251 * Asynchronously sends batches of requests
252 * @param {number} index
253 */
254 function send_next_request_batch(index) {
255 var request_batch = request_batches[index],
256 cb = function (errors, results) {
257 index += 1;
258 if (index === total_request_batches) {
259 callback && callback(errors, results);
260 } else {
261 send_next_request_batch(index);
262 }
263 };
264
265 async_all(request_batch, send_event_batch, cb);
266 }
267
268 // init recursive function
269 send_next_request_batch(0);
270
271 if (metrics.config.debug) {
272 console.log(
273 "Sending " + event_list.length + " events to Mixpanel in " +
274 total_event_batches + " batches of events and " +
275 total_request_batches + " batches of requests"
276 );
277 }
278 };
279
280 /**
281 track(event, properties, callback)
282 ---
283 this function sends an event to mixpanel.
284
285 event:string the event name
286 properties:object additional event properties to send
287 callback:function(err:Error) callback is called when the request is
288 finished or an error occurs
289 */
290 metrics.track = function(event, properties, callback) {
291 if (!properties || typeof properties === "function") {
292 callback = properties;
293 properties = {};
294 }
295
296 // time is optional for `track`
297 if (properties.time) {
298 properties.time = ensure_timestamp(properties.time);
299 }
300
301 metrics.send_event_request("/track", event, properties, callback);
302 };
303
304 /**
305 * send a batch of events to mixpanel `track` endpoint: this should only be used if events are less than 5 days old
306 * @param {Array} event_list array of event objects to track
307 * @param {object} [options]
308 * @param {number} [options.max_concurrent_requests] number of concurrent http requests that can be made to mixpanel
309 * @param {number} [options.max_batch_size] number of events that can be sent to mixpanel per request
310 * @param {Function} [callback] callback receives array of errors if any
311 */
312 metrics.track_batch = function(event_list, options, callback) {
313 options = options || {};
314 if (typeof options === 'function') {
315 callback = options;
316 options = {};
317 }
318 var batch_options = {
319 event_list: event_list,
320 endpoint: "/track",
321 max_concurrent_requests: options.max_concurrent_requests,
322 max_batch_size: options.max_batch_size
323 };
324
325 send_batch_requests(batch_options, callback);
326 };
327
328 /**
329 import(event, time, properties, callback)
330 ---
331 This function sends an event to mixpanel using the import
332 endpoint. The time argument should be either a Date or Number,
333 and should signify the time the event occurred.
334
335 It is highly recommended that you specify the distinct_id
336 property for each event you import, otherwise the events will be
337 tied to the IP address of the sending machine.
338
339 For more information look at:
340 https://mixpanel.com/docs/api-documentation/importing-events-older-than-31-days
341
342 event:string the event name
343 time:date|number the time of the event
344 properties:object additional event properties to send
345 callback:function(err:Error) callback is called when the request is
346 finished or an error occurs
347 */
348 metrics.import = function(event, time, properties, callback) {
349 if (!properties || typeof properties === "function") {
350 callback = properties;
351 properties = {};
352 }
353
354 properties.time = ensure_timestamp(time);
355
356 metrics.send_event_request("/import", event, properties, callback);
357 };
358
359 /**
360 import_batch(event_list, options, callback)
361 ---
362 This function sends a list of events to mixpanel using the import
363 endpoint. The format of the event array should be:
364
365 [
366 {
367 "event": "event name",
368 "properties": {
369 "time": new Date(), // Number or Date; required for each event
370 "key": "val",
371 ...
372 }
373 },
374 {
375 "event": "event name",
376 "properties": {
377 "time": new Date() // Number or Date; required for each event
378 }
379 },
380 ...
381 ]
382
383 See import() for further information about the import endpoint.
384
385 Options:
386 max_batch_size: the maximum number of events to be transmitted over
387 the network simultaneously. useful for capping bandwidth
388 usage.
389 max_concurrent_requests: the maximum number of concurrent http requests that
390 can be made to mixpanel; also useful for capping bandwidth.
391
392 N.B.: the Mixpanel API only accepts 50 events per request, so regardless
393 of max_batch_size, larger lists of events will be chunked further into
394 groups of 50.
395
396 event_list:array list of event names and properties
397 options:object optional batch configuration
398 callback:function(error_list:array) callback is called when the request is
399 finished or an error occurs
400 */
401 metrics.import_batch = function(event_list, options, callback) {
402 var batch_options;
403
404 if (typeof(options) === "function" || !options) {
405 callback = options;
406 options = {};
407 }
408 batch_options = {
409 event_list: event_list,
410 endpoint: "/import",
411 max_concurrent_requests: options.max_concurrent_requests,
412 max_batch_size: options.max_batch_size
413 };
414 send_batch_requests(batch_options, callback);
415 };
416
417 /**
418 alias(distinct_id, alias)
419 ---
420 This function creates an alias for distinct_id
421
422 For more information look at:
423 https://mixpanel.com/docs/integration-libraries/using-mixpanel-alias
424
425 distinct_id:string the current identifier
426 alias:string the future alias
427 */
428 metrics.alias = function(distinct_id, alias, callback) {
429 var properties = {
430 distinct_id: distinct_id,
431 alias: alias
432 };
433
434 metrics.track('$create_alias', properties, callback);
435 };
436
437 metrics.groups = new MixpanelGroups(metrics);
438 metrics.people = new MixpanelPeople(metrics);
439
440 /**
441 set_config(config)
442 ---
443 Modifies the mixpanel config
444
445 config:object an object with properties to override in the
446 mixpanel client config
447 */
448 metrics.set_config = function(config) {
449 Object.assign(metrics.config, config);
450 if (config.host) {
451 // Split host into host and port
452 const [host, port] = config.host.split(':');
453 metrics.config.host = host;
454 if (port) {
455 metrics.config.port = Number(port);
456 }
457 }
458 };
459
460 if (config) {
461 metrics.set_config(config);
462 }
463
464 return metrics;
465};
466
467// module exporting
468module.exports = {
469 Client: function(token) {
470 console.warn("The function `Client(token)` is deprecated. It is now called `init(token)`.");
471 return create_client(token);
472 },
473 init: create_client
474};