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 |
|
10 | const querystring = require('querystring');
|
11 | const Buffer = require('buffer').Buffer;
|
12 | const http = require('http');
|
13 | const https = require('https');
|
14 | const HttpsProxyAgent = require('https-proxy-agent');
|
15 | const url = require('url');
|
16 | const packageInfo = require('../package.json')
|
17 |
|
18 | const {async_all, ensure_timestamp} = require('./utils');
|
19 | const {MixpanelGroups} = require('./groups');
|
20 | const {MixpanelPeople} = require('./people');
|
21 |
|
22 | const 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 |
|
35 | var 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
|
468 | module.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 | };
|