├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── example.js ├── history.md ├── lib ├── groups.js ├── mixpanel-node.d.ts ├── mixpanel-node.js ├── people.js ├── profile_helpers.js └── utils.js ├── package-lock.json ├── package.json ├── readme.md ├── test ├── alias.js ├── config.js ├── groups.js ├── import.js ├── logger.js ├── people.js ├── send_request.js ├── track.js └── utils.js └── vitest.config.mjs /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [18.x, 20.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | - run: npm test -- run --coverage 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | 4 | # ide files 5 | .idea 6 | 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | .github/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Carl Sverre 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | // grab the Mixpanel factory 2 | var Mixpanel = require('./lib/mixpanel-node'); 3 | 4 | // create an instance of the mixpanel client 5 | var mixpanel = Mixpanel.init('962dbca1bbc54701d402c94d65b4a20e'); 6 | mixpanel.set_config({ debug: true }); 7 | 8 | // track an event with optional properties 9 | mixpanel.track("my event", { 10 | distinct_id: "some unique client id", 11 | as: "many", 12 | properties: "as", 13 | you: "want" 14 | }); 15 | mixpanel.track("played_game"); 16 | 17 | // create or update a user in Mixpanel Engage 18 | mixpanel.people.set("billybob", { 19 | $first_name: "Billy", 20 | $last_name: "Bob", 21 | $created: (new Date('jan 1 2013')).toISOString(), 22 | plan: "premium", 23 | games_played: 1, 24 | points: 0 25 | }); 26 | 27 | // create or update a user in Mixpanel Engage without altering $last_seen 28 | // - pass option `$ignore_time: true` to prevent the $last_seen property from being updated 29 | mixpanel.people.set("billybob", { 30 | plan: "premium", 31 | games_played: 1 32 | }, { 33 | $ignore_time: true 34 | }); 35 | 36 | // set a single property on a user 37 | mixpanel.people.set("billybob", "plan", "free"); 38 | 39 | // set a single property on a user, don't override 40 | mixpanel.people.set_once("billybob", "first_game_play", (new Date('jan 1 2013')).toISOString()); 41 | 42 | // increment a numeric property 43 | mixpanel.people.increment("billybob", "games_played"); 44 | 45 | // increment a numeric property by a different amount 46 | mixpanel.people.increment("billybob", "points", 15); 47 | 48 | // increment multiple properties 49 | mixpanel.people.increment("billybob", {"points": 10, "games_played": 1}); 50 | 51 | // append value to a list 52 | mixpanel.people.append("billybob", "awards", "Great Player"); 53 | 54 | // append multiple values to a list 55 | mixpanel.people.append("billybob", {"awards": "Great Player", "levels_finished": "Level 4"}); 56 | 57 | // record a transaction for revenue analytics 58 | mixpanel.people.track_charge("billybob", 39.99); 59 | 60 | // clear a users transaction history 61 | mixpanel.people.clear_charges("billybob"); 62 | 63 | // delete a user 64 | mixpanel.people.delete_user("billybob"); 65 | 66 | // all functions that send data to mixpanel take an optional 67 | // callback as the last argument 68 | mixpanel.track("test", function(err) { if (err) { throw err; } }); 69 | 70 | // import an old event 71 | var mixpanel_importer = Mixpanel.init('valid mixpanel token', { 72 | secret: "valid api secret for project" 73 | }); 74 | mixpanel_importer.set_config({ debug: true }); 75 | 76 | // needs to be in the system once for it to show up in the interface 77 | mixpanel_importer.track('old event', { gender: '' }); 78 | 79 | mixpanel_importer.import("old event", new Date(2012, 4, 20, 12, 34, 56), { 80 | distinct_id: 'billybob', 81 | gender: 'male' 82 | }); 83 | 84 | // import multiple events at once 85 | mixpanel_importer.import_batch([ 86 | { 87 | event: 'old event', 88 | properties: { 89 | time: new Date(2012, 4, 20, 12, 34, 56), 90 | distinct_id: 'billybob', 91 | gender: 'male' 92 | } 93 | }, 94 | { 95 | event: 'another old event', 96 | properties: { 97 | time: new Date(2012, 4, 21, 11, 33, 55), 98 | distinct_id: 'billybob', 99 | color: 'red' 100 | } 101 | } 102 | ]); 103 | -------------------------------------------------------------------------------- /history.md: -------------------------------------------------------------------------------- 1 | 0.18.1 / 2025-03-12 2 | ================== 3 | * add secret to config types (thanks gierschv) 4 | 5 | 0.18.0 / 2023-09-12 6 | ================== 7 | * custom logger support (thanks iatsiuk) 8 | 9 | 0.17.0 / 2022-08-11 10 | ================== 11 | * support sending timestamps with millisecond precision 12 | 13 | 0.16.0 / 2022-06-02 14 | ================== 15 | * support automatic geolocation with `geolocate` option (thanks tmpvar) 16 | * send library version as property with events (thanks ArsalImam) 17 | 18 | 0.15.0 / 2022-05-20 19 | ================== 20 | * use keepAlive by default for requests 21 | 22 | 0.14.0 / 2021-10-29 23 | ================== 24 | * support $latitude and $longitude in profile operations (thanks wneild) 25 | 26 | 0.13.0 / 2020-09-04 27 | ================== 28 | * support API Secret auth for imports and deprecate use of API Key 29 | 30 | 0.12.0 / 2020-08-31 31 | ================== 32 | * https-proxy-agent upgrade to 5.0.0 to fix https.request patching and many subdependency upgrades (thanks veerabio) 33 | * dropped support for node 8 34 | 35 | 0.11.0 / 2019-11-26 36 | ================== 37 | * add support for Groups API 38 | 39 | 0.10.3 / 2019-10-09 40 | ================== 41 | * upgrade https-proxy-agent for security fix (thanks omrilotan) 42 | 43 | 0.10.2 / 2019-03-26 44 | ================== 45 | * type definitions for people.unset (thanks bradleyayers) 46 | 47 | 0.10.1 / 2018-12-03 48 | ================== 49 | * support configurable API path (thanks CameronDiver) 50 | 51 | 0.9.2 / 2018-05-22 52 | ================== 53 | * add type declarations file (thanks mklopets) 54 | 55 | 0.9.1 / 2018-04-12 56 | ================== 57 | * upgrade https-proxy-agent for security fix 58 | 59 | 0.9.0 / 2018-02-09 60 | ================== 61 | * default to tracking over HTTPS (thanks jhermsmeier) 62 | 63 | 0.8.0 / 2017-11-28 64 | ================== 65 | * upgraded node-https-proxy-agent to v2.1.1 for security patch (see 66 | https://github.com/TooTallNate/node-https-proxy-agent/issues/37) 67 | 68 | 0.7.0 / 2017-04-07 69 | =================== 70 | * added `track_batch` for tracking multiple recent events per request (thanks cruzanmo) 71 | * support for routing requests through proxy server specified in env var `HTTPS_PROXY` 72 | or `HTTP_PROXY` (thanks colestrode) 73 | * dropped support for node 0.10 and 0.12 74 | 75 | 0.6.0 / 2017-01-03 76 | =================== 77 | * support for `time` field in `mixpanel.track()` (thanks cruzanmo) 78 | 79 | 0.5.0 / 2016-09-15 80 | =================== 81 | * optional https support (thanks chiangf) 82 | 83 | 0.4.1 / 2016-09-09 84 | =================== 85 | * include `$ignore_alias` in permitted `people` modifiers (thanks Left47) 86 | 87 | 0.4.0 / 2016-02-09 88 | =================== 89 | * allow optional `modifiers` in all `people` calls for `$ignore_time`, `$ip`, 90 | and `$time` fields 91 | 92 | 0.3.2 / 2015-12-10 93 | =================== 94 | * correct `$delete` field in `people.delete_user` request (thanks godspeedelbow) 95 | 96 | 0.3.1 / 2015-08-06 97 | =================== 98 | * added config option for API host (thanks gmichael225) 99 | 100 | 0.3.0 / 2015-08-06 101 | =================== 102 | * added people.union support (thanks maeldur) 103 | 104 | 0.2.0 / 2015-04-14 105 | =================== 106 | * added batch import support 107 | 108 | 0.1.1 / 2015-03-27 109 | =================== 110 | * fixed callback behavior in track_charges when no properties supplied 111 | (thanks sorribas) 112 | 113 | 0.1.0 / 2015-03-20 114 | =================== 115 | * updated URL metadata (thanks freeall) 116 | * updated dev dependencies 117 | * added builds for iojs, node 0.12, dropped support for node <0.10 118 | 119 | 0.0.20 / 2014-05-11 120 | ==================== 121 | * removed hardcoded port 80 for more flexibility (thanks zeevl) 122 | 123 | 0.0.19 / 2014.04.03 124 | ==================== 125 | * added people.append (thanks jylauril) 126 | 127 | 0.0.18 / 2013-08-23 128 | ==================== 129 | * added callback to alias (thanks to sandinmyjoints) 130 | * added verbose config option (thanks to sandinmyjoints) 131 | * added unset method (thanks to lukapril) 132 | 133 | 0.0.17 / 2013-08-12 134 | ==================== 135 | * added alias method (thanks to PierrickP) 136 | 137 | 0.0.16 / 2013-06-29 138 | ==================== 139 | * allow special key "ip" to be 0 in people.set (thanks to wwlinx) 140 | 141 | 0.0.15 / 2013-05-24 142 | ==================== 143 | * adds set once functionality to people (thanks to avoid3d) 144 | * $ignore_time in people.set (thanks to Rick Cotter) 145 | 146 | 0.0.14 / 2013-03-28 147 | ==================== 148 | * revert Randal's http only patch since Mixpanel indeed supports https. 149 | * handles the ip property in a property object properly for people calls 150 | 151 | 0.0.13 / 2013-03-25 152 | ==================== 153 | * force requests to go over http [reverted in 0.0.14] 154 | 155 | 0.0.12 / 2013-01-24 156 | ==================== 157 | * track_charge() no longer includes $time by default, rather it lets 158 | Mixpanel's servers set the time when they receive the transaction. This 159 | doesn't modify the ability for the user to pass in their own $time (for 160 | importing transactions). 161 | 162 | 0.0.11 / 2013-01-11 163 | ==================== 164 | * added track_charge() method which provides the ability to record user 165 | transactions for revenue analytics. 166 | * added clear_charges() method which provides the ability to remove a 167 | users transactions from Mixpanel 168 | * added tests for delete_user() 169 | 170 | 0.0.10 / 2012-11-26 171 | ==================== 172 | * added import() method which provides the ability to import events 173 | older than 5 days. Contributions from Thomas Watson Steen. 174 | 175 | 0.0.9 / 2012-11-15 176 | =================== 177 | * removed time from properties sent to server. This is to ensure that 178 | UTC is always used. Mixpanel will set the correct time as soon as they 179 | receive the event. 180 | 181 | 0.0.8 / 2012-10-24 182 | =================== 183 | * added mp_lib property, so people can segment by library 184 | 185 | 0.0.7 / 2012-01-05 186 | =================== 187 | * added unit tests 188 | * people.increment() only prints error message if debug is true 189 | 190 | 0.0.6 / 2012-01-01 191 | =================== 192 | * added engage support 193 | * people.set() 194 | * people.increment() 195 | * people.delete_user() 196 | * deprecated old constructor: require("mixpanel").Client(token) 197 | * added new constructor: require("mixpanel").init(token) 198 | -------------------------------------------------------------------------------- /lib/groups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Group profile methods. Learn more: https://help.mixpanel.com/hc/en-us/articles/360025333632 3 | */ 4 | 5 | const {ProfileHelpers} = require('./profile_helpers'); 6 | 7 | class MixpanelGroups extends ProfileHelpers() { 8 | constructor(mp_instance) { 9 | super(); 10 | this.mixpanel = mp_instance; 11 | this.endpoint = '/groups'; 12 | } 13 | 14 | /** groups.set_once(group_key, group_id, prop, to, modifiers, callback) 15 | --- 16 | The same as groups.set, but adds a property value to a group only if it has not been set before. 17 | */ 18 | set_once(group_key, group_id, prop, to, modifiers, callback) { 19 | const identifiers = {$group_key: group_key, $group_id: group_id}; 20 | this._set(prop, to, modifiers, callback, {identifiers, set_once: true}); 21 | } 22 | 23 | /** 24 | groups.set(group_key, group_id, prop, to, modifiers, callback) 25 | --- 26 | set properties on a group profile 27 | 28 | usage: 29 | 30 | mixpanel.groups.set('company', 'Acme Inc.', '$name', 'Acme Inc.'); 31 | 32 | mixpanel.groups.set('company', 'Acme Inc.', { 33 | 'Industry': 'widgets', 34 | '$name': 'Acme Inc.', 35 | }); 36 | */ 37 | set(group_key, group_id, prop, to, modifiers, callback) { 38 | const identifiers = {$group_key: group_key, $group_id: group_id}; 39 | this._set(prop, to, modifiers, callback, {identifiers}); 40 | } 41 | 42 | /** 43 | groups.delete_group(group_key, group_id, modifiers, callback) 44 | --- 45 | delete a group profile permanently 46 | 47 | usage: 48 | 49 | mixpanel.groups.delete_group('company', 'Acme Inc.'); 50 | */ 51 | delete_group(group_key, group_id, modifiers, callback) { 52 | const identifiers = {$group_key: group_key, $group_id: group_id}; 53 | this._delete_profile({identifiers, modifiers, callback}); 54 | } 55 | 56 | /** 57 | groups.remove(group_key, group_id, data, modifiers, callback) 58 | --- 59 | remove a value from a list-valued group profile property. 60 | 61 | usage: 62 | 63 | mixpanel.groups.remove('company', 'Acme Inc.', {'products': 'anvil'}); 64 | 65 | mixpanel.groups.remove('company', 'Acme Inc.', { 66 | 'products': 'anvil', 67 | 'customer segments': 'coyotes' 68 | }); 69 | */ 70 | remove(group_key, group_id, data, modifiers, callback) { 71 | const identifiers = {$group_key: group_key, $group_id: group_id}; 72 | this._remove({identifiers, data, modifiers, callback}); 73 | } 74 | 75 | /** 76 | groups.union(group_key, group_id, data, modifiers, callback) 77 | --- 78 | merge value(s) into a list-valued group profile property. 79 | 80 | usage: 81 | 82 | mixpanel.groups.union('company', 'Acme Inc.', {'products': 'anvil'}); 83 | 84 | mixpanel.groups.union('company', 'Acme Inc.', {'products': ['anvil'], 'customer segments': ['coyotes']}); 85 | */ 86 | union(group_key, group_id, data, modifiers, callback) { 87 | const identifiers = {$group_key: group_key, $group_id: group_id}; 88 | this._union({identifiers, data, modifiers, callback}) 89 | } 90 | 91 | /** 92 | groups.unset(group_key, group_id, prop, modifiers, callback) 93 | --- 94 | delete a property on a group profile 95 | 96 | usage: 97 | 98 | mixpanel.groups.unset('company', 'Acme Inc.', 'products'); 99 | 100 | mixpanel.groups.unset('company', 'Acme Inc.', ['products', 'customer segments']); 101 | */ 102 | unset(group_key, group_id, prop, modifiers, callback) { 103 | const identifiers = {$group_key: group_key, $group_id: group_id}; 104 | this._unset({identifiers, prop, modifiers, callback}) 105 | } 106 | } 107 | 108 | exports.MixpanelGroups = MixpanelGroups; 109 | -------------------------------------------------------------------------------- /lib/mixpanel-node.d.ts: -------------------------------------------------------------------------------- 1 | declare const mixpanel: mixpanel.Mixpanel; 2 | 3 | declare namespace mixpanel { 4 | export type Callback = (err: Error | undefined) => any; 5 | export type BatchCallback = (errors: [Error] | undefined) => any; 6 | 7 | type Scalar = string | number | boolean; 8 | 9 | export interface CustomLogger { 10 | trace(message?: any, ...optionalParams: any[]): void; 11 | debug(message?: any, ...optionalParams: any[]): void; 12 | info(message?: any, ...optionalParams: any[]): void; 13 | warn(message?: any, ...optionalParams: any[]): void; 14 | error(message?: any, ...optionalParams: any[]): void; 15 | } 16 | 17 | export interface InitConfig { 18 | test: boolean; 19 | debug: boolean; 20 | verbose: boolean; 21 | host: string; 22 | protocol: string; 23 | path: string; 24 | secret: string; 25 | keepAlive: boolean; 26 | geolocate: boolean; 27 | logger: CustomLogger; 28 | } 29 | 30 | export interface PropertyDict { 31 | [key: string]: any; 32 | } 33 | 34 | export interface NumberMap { 35 | [key: string]: number; 36 | } 37 | 38 | export interface Event { 39 | event: string; 40 | properties: PropertyDict; 41 | } 42 | export interface Modifiers { 43 | $ip?: string; 44 | $ignore_time?: boolean; 45 | $time?: string; 46 | $ignore_alias?: boolean; 47 | $latitude?: number; 48 | $longitude?: number; 49 | } 50 | 51 | export interface BatchOptions { 52 | max_concurrent_requests?: number; 53 | max_batch_size?: number; 54 | } 55 | 56 | export interface UnionData { 57 | [key: string]: Scalar | Scalar[]; 58 | } 59 | 60 | export interface RemoveData { 61 | [key: string]: string | number 62 | } 63 | 64 | interface Mixpanel { 65 | init(mixpanelToken: string, config?: Partial): Mixpanel; 66 | 67 | track(eventName: string, callback?: Callback): void; 68 | track(eventName: string, properties: PropertyDict, callback?: Callback): void; 69 | 70 | track_batch(events: Event[], options?: BatchOptions, callback?: BatchCallback): void; 71 | track_batch(events: Event[], callback: BatchCallback): void; 72 | track_batch(eventNames: string[], options?: BatchOptions, callback?: BatchCallback): void; 73 | track_batch(eventNames: string[], callback?: BatchCallback): void; 74 | 75 | import(eventName: string, time: Date | number, properties?: PropertyDict, callback?: Callback): void; 76 | import(eventName: string, time: Date | number, callback: Callback): void; 77 | 78 | import_batch(eventNames: string[], options?: BatchOptions, callback?: BatchCallback): void; 79 | import_batch(eventNames: string[], callback?: BatchCallback): void; 80 | import_batch(events: Event[], callback?: BatchCallback): void; 81 | 82 | alias(distinctId: string, alias: string, callback?: Callback): void; 83 | 84 | people: People; 85 | 86 | groups: Groups; 87 | } 88 | 89 | interface People { 90 | set(distinctId: string, properties: PropertyDict, callback?: Callback): void; 91 | set(distinctId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; 92 | set(distinctId: string, propertyName: string, value: string | number, modifiers: Modifiers): void; 93 | set(distinctId: string, propertyName: string, value: string | number, callback?: Callback): void; 94 | set(distinctId: string, propertyName: string, value: string | number, modifiers: Modifiers, callback: Callback): void; 95 | 96 | unset(distinctId: string, propertyName: string | string[], callback?: Callback): void; 97 | unset(distinctId: string, propertyName: string | string[], modifiers?: Modifiers, callback?: Callback): void; 98 | 99 | set_once(distinctId: string, propertyName: string, value: string, callback?: Callback): void; 100 | set_once(distinctId: string, propertyName: string, value: string, modifiers: Modifiers, callback?: Callback): void; 101 | set_once(distinctId: string, properties: PropertyDict, callback?: Callback): void; 102 | set_once(distinctId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; 103 | 104 | increment(distinctId: string, propertyName: string, modifiers?: Modifiers, callback?: Callback): void; 105 | increment(distinctId: string, propertyName: string, incrementBy: number, modifiers: Modifiers, callback?: Callback): void; 106 | increment(distinctId: string, propertyName: string, incrementBy: number, callback?: Callback): void; 107 | increment(distinctId: string, properties: NumberMap, modifiers: Modifiers, callback?: Callback): void; 108 | increment(distinctId: string, properties: NumberMap, callback?: Callback): void; 109 | 110 | append(distinctId: string, propertyName: string, value: any, modifiers: Modifiers, callback?: Callback): void; 111 | append(distinctId: string, propertyName: string, value: any, callback?: Callback): void; 112 | append(distinctId: string, properties: PropertyDict, callback?: Callback): void; 113 | append(distinctId: string, properties: PropertyDict, modifiers: Modifiers, callback?: Callback): void; 114 | 115 | union(distinctId: string, data: UnionData, modifiers?: Modifiers, callback?: Callback): void; 116 | union(distinctId: string, data: UnionData, callback: Callback): void; 117 | 118 | remove(distinctId: string, data: RemoveData, modifiers?: Modifiers, callback?: Callback): void; 119 | remove(distinctId: string, data: RemoveData, callback: Callback): void; 120 | 121 | track_charge(distinctId: string, amount: number | string, properties?: PropertyDict, callback?: Callback): void; 122 | track_charge(distinctId: string, amount: number | string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; 123 | 124 | clear_charges(distinctId: string, modifiers?: Modifiers, callback?: Callback): void; 125 | clear_charges(distinctId: string, callback: Callback): void; 126 | 127 | delete_user(distinctId: string, modifiers?: Modifiers, callback?: Callback): void; 128 | delete_user(distinctId: string, callback: Callback): void; 129 | } 130 | 131 | interface Groups { 132 | set(groupKey: string, groupId: string, properties: PropertyDict, callback?: Callback): void; 133 | set(groupKey: string, groupId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; 134 | set(groupKey: string, groupId: string, propertyName: string, value: string | number, modifiers: Modifiers): void; 135 | set(groupKey: string, groupId: string, propertyName: string, value: string | number, callback?: Callback): void; 136 | set(groupKey: string, groupId: string, propertyName: string, value: string | number, modifiers: Modifiers, callback: Callback): void; 137 | 138 | unset(groupKey: string, groupId: string, propertyName: string | string[], callback?: Callback): void; 139 | unset(groupKey: string, groupId: string, propertyName: string | string[], modifiers?: Modifiers, callback?: Callback): void; 140 | 141 | set_once(groupKey: string, groupId: string, propertyName: string, value: string, callback?: Callback): void; 142 | set_once( groupKey: string, groupId: string, propertyName: string, value: string, modifiers: Modifiers, callback?: Callback): void; 143 | set_once(groupKey: string, groupId: string, properties: PropertyDict, callback?: Callback): void; 144 | set_once(groupKey: string, groupId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; 145 | 146 | union(groupKey: string, groupId: string, data: UnionData, modifiers?: Modifiers, callback?: Callback): void; 147 | union(groupKey: string, groupId: string, data: UnionData, callback: Callback): void; 148 | 149 | remove(groupKey: string, groupId: string, data: RemoveData, modifiers?: Modifiers, callback?: Callback): void; 150 | remove(groupKey: string, groupId: string, data: RemoveData, callback: Callback): void; 151 | 152 | delete_group(groupKey: string, groupId: string, modifiers?: Modifiers, callback?: Callback): void; 153 | delete_group(groupKey: string, groupId: string, callback: Callback): void; 154 | } 155 | } 156 | 157 | export = mixpanel; 158 | -------------------------------------------------------------------------------- /lib/mixpanel-node.js: -------------------------------------------------------------------------------- 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, assert_logger} = 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 | logger: console, 34 | }; 35 | 36 | var create_client = function(token, config) { 37 | if (!token) { 38 | throw new Error("The Mixpanel Client needs a Mixpanel token: `init(token)`"); 39 | } 40 | 41 | const metrics = { 42 | token, 43 | config: {...DEFAULT_CONFIG}, 44 | }; 45 | const {keepAlive} = metrics.config; 46 | 47 | // mixpanel constants 48 | const MAX_BATCH_SIZE = 50; 49 | const REQUEST_LIBS = {http, https}; 50 | const REQUEST_AGENTS = { 51 | http: new http.Agent({keepAlive}), 52 | https: new https.Agent({keepAlive}), 53 | }; 54 | const proxyPath = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; 55 | const proxyAgent = proxyPath ? new HttpsProxyAgent(Object.assign(url.parse(proxyPath), { 56 | keepAlive, 57 | })) : null; 58 | 59 | /** 60 | * sends an async GET or POST request to mixpanel 61 | * for batch processes data must be send in the body of a POST 62 | * @param {object} options 63 | * @param {string} options.endpoint 64 | * @param {object} options.data the data to send in the request 65 | * @param {string} [options.method] e.g. `get` or `post`, defaults to `get` 66 | * @param {function} callback called on request completion or error 67 | */ 68 | metrics.send_request = function(options, callback) { 69 | callback = callback || function() {}; 70 | 71 | let content = Buffer.from(JSON.stringify(options.data)).toString('base64'); 72 | const endpoint = options.endpoint; 73 | const method = (options.method || 'GET').toUpperCase(); 74 | let query_params = { 75 | 'ip': metrics.config.geolocate ? 1 : 0, 76 | 'verbose': metrics.config.verbose ? 1 : 0 77 | }; 78 | const key = metrics.config.key; 79 | const secret = metrics.config.secret; 80 | const request_lib = REQUEST_LIBS[metrics.config.protocol]; 81 | let request_options = { 82 | host: metrics.config.host, 83 | port: metrics.config.port, 84 | headers: {}, 85 | method: method 86 | }; 87 | let request; 88 | 89 | if (!request_lib) { 90 | throw new Error( 91 | "Mixpanel Initialization Error: Unsupported protocol " + metrics.config.protocol + ". " + 92 | "Supported protocols are: " + Object.keys(REQUEST_LIBS) 93 | ); 94 | } 95 | 96 | 97 | if (method === 'POST') { 98 | content = 'data=' + content; 99 | request_options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; 100 | request_options.headers['Content-Length'] = Buffer.byteLength(content); 101 | } else if (method === 'GET') { 102 | query_params.data = content; 103 | } 104 | 105 | 106 | // add auth params 107 | if (secret) { 108 | if (request_lib !== https) { 109 | throw new Error("Must use HTTPS if authenticating with API Secret"); 110 | } 111 | const encoded = Buffer.from(secret + ':').toString('base64'); 112 | request_options.headers['Authorization'] = 'Basic ' + encoded; 113 | } else if (key) { 114 | query_params.api_key = key; 115 | } else if (endpoint === '/import') { 116 | throw new Error("The Mixpanel Client needs a Mixpanel API Secret when importing old events: `init(token, { secret: ... })`"); 117 | } 118 | 119 | request_options.agent = proxyAgent || REQUEST_AGENTS[metrics.config.protocol]; 120 | 121 | if (metrics.config.test) { 122 | query_params.test = 1; 123 | } 124 | 125 | request_options.path = metrics.config.path + endpoint + "?" + querystring.stringify(query_params); 126 | 127 | request = request_lib.request(request_options, function(res) { 128 | var data = ""; 129 | res.on('data', function(chunk) { 130 | data += chunk; 131 | }); 132 | 133 | res.on('end', function() { 134 | var e; 135 | if (metrics.config.verbose) { 136 | try { 137 | var result = JSON.parse(data); 138 | if(result.status != 1) { 139 | e = new Error("Mixpanel Server Error: " + result.error); 140 | } 141 | } 142 | catch(ex) { 143 | e = new Error("Could not parse response from Mixpanel"); 144 | } 145 | } 146 | else { 147 | e = (data !== '1') ? new Error("Mixpanel Server Error: " + data) : undefined; 148 | } 149 | 150 | callback(e); 151 | }); 152 | }); 153 | 154 | request.on('error', function(e) { 155 | if (metrics.config.debug) { 156 | metrics.config.logger.error("Got Error: " + e.message); 157 | } 158 | callback(e); 159 | }); 160 | 161 | if (method === 'POST') { 162 | request.write(content); 163 | } 164 | request.end(); 165 | }; 166 | 167 | /** 168 | * Send an event to Mixpanel, using the specified endpoint (e.g., track/import) 169 | * @param {string} endpoint - API endpoint name 170 | * @param {string} event - event name 171 | * @param {object} properties - event properties 172 | * @param {Function} [callback] - callback for request completion/error 173 | */ 174 | metrics.send_event_request = function(endpoint, event, properties, callback) { 175 | properties.token = metrics.token; 176 | properties.mp_lib = "node"; 177 | properties.$lib_version = packageInfo.version; 178 | 179 | var data = { 180 | event: event, 181 | properties: properties 182 | }; 183 | 184 | if (metrics.config.debug) { 185 | metrics.config.logger.debug("Sending the following event to Mixpanel", { data }); 186 | } 187 | 188 | metrics.send_request({ method: "GET", endpoint: endpoint, data: data }, callback); 189 | }; 190 | 191 | /** 192 | * breaks array into equal-sized chunks, with the last chunk being the remainder 193 | * @param {Array} arr 194 | * @param {number} size 195 | * @returns {Array} 196 | */ 197 | var chunk = function(arr, size) { 198 | var chunks = [], 199 | i = 0, 200 | total = arr.length; 201 | 202 | while (i < total) { 203 | chunks.push(arr.slice(i, i += size)); 204 | } 205 | return chunks; 206 | }; 207 | 208 | /** 209 | * sends events in batches 210 | * @param {object} options 211 | * @param {[{}]} options.event_list array of event objects 212 | * @param {string} options.endpoint e.g. `/track` or `/import` 213 | * @param {number} [options.max_concurrent_requests] limits concurrent async requests over the network 214 | * @param {number} [options.max_batch_size] limits number of events sent to mixpanel per request 215 | * @param {Function} [callback] callback receives array of errors if any 216 | * 217 | */ 218 | var send_batch_requests = function(options, callback) { 219 | var event_list = options.event_list, 220 | endpoint = options.endpoint, 221 | max_batch_size = options.max_batch_size ? Math.min(MAX_BATCH_SIZE, options.max_batch_size) : MAX_BATCH_SIZE, 222 | // 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 223 | max_concurrent_requests = options.max_concurrent_requests || (options.max_batch_size > MAX_BATCH_SIZE && Math.ceil(options.max_batch_size / MAX_BATCH_SIZE)), 224 | event_batches = chunk(event_list, max_batch_size), 225 | request_batches = max_concurrent_requests ? chunk(event_batches, max_concurrent_requests) : [event_batches], 226 | total_event_batches = event_batches.length, 227 | total_request_batches = request_batches.length; 228 | 229 | /** 230 | * sends a batch of events to mixpanel through http api 231 | * @param {Array} batch 232 | * @param {Function} cb 233 | */ 234 | function send_event_batch(batch, cb) { 235 | if (batch.length > 0) { 236 | batch = batch.map(function (event) { 237 | var properties = event.properties; 238 | if (endpoint === '/import' || event.properties.time) { 239 | // usually there will be a time property, but not required for `/track` endpoint 240 | event.properties.time = ensure_timestamp(event.properties.time); 241 | } 242 | event.properties.token = event.properties.token || metrics.token; 243 | return event; 244 | }); 245 | 246 | // must be a POST 247 | metrics.send_request({ method: "POST", endpoint: endpoint, data: batch }, cb); 248 | } 249 | } 250 | 251 | /** 252 | * Asynchronously sends batches of requests 253 | * @param {number} index 254 | */ 255 | function send_next_request_batch(index) { 256 | var request_batch = request_batches[index], 257 | cb = function (errors, results) { 258 | index += 1; 259 | if (index === total_request_batches) { 260 | callback && callback(errors, results); 261 | } else { 262 | send_next_request_batch(index); 263 | } 264 | }; 265 | 266 | async_all(request_batch, send_event_batch, cb); 267 | } 268 | 269 | // init recursive function 270 | send_next_request_batch(0); 271 | 272 | if (metrics.config.debug) { 273 | metrics.config.logger.debug( 274 | "Sending " + event_list.length + " events to Mixpanel in " + 275 | total_event_batches + " batches of events and " + 276 | total_request_batches + " batches of requests" 277 | ); 278 | } 279 | }; 280 | 281 | /** 282 | track(event, properties, callback) 283 | --- 284 | this function sends an event to mixpanel. 285 | 286 | event:string the event name 287 | properties:object additional event properties to send 288 | callback:function(err:Error) callback is called when the request is 289 | finished or an error occurs 290 | */ 291 | metrics.track = function(event, properties, callback) { 292 | if (!properties || typeof properties === "function") { 293 | callback = properties; 294 | properties = {}; 295 | } 296 | 297 | // time is optional for `track` 298 | if (properties.time) { 299 | properties.time = ensure_timestamp(properties.time); 300 | } 301 | 302 | metrics.send_event_request("/track", event, properties, callback); 303 | }; 304 | 305 | /** 306 | * send a batch of events to mixpanel `track` endpoint: this should only be used if events are less than 5 days old 307 | * @param {Array} event_list array of event objects to track 308 | * @param {object} [options] 309 | * @param {number} [options.max_concurrent_requests] number of concurrent http requests that can be made to mixpanel 310 | * @param {number} [options.max_batch_size] number of events that can be sent to mixpanel per request 311 | * @param {Function} [callback] callback receives array of errors if any 312 | */ 313 | metrics.track_batch = function(event_list, options, callback) { 314 | options = options || {}; 315 | if (typeof options === 'function') { 316 | callback = options; 317 | options = {}; 318 | } 319 | var batch_options = { 320 | event_list: event_list, 321 | endpoint: "/track", 322 | max_concurrent_requests: options.max_concurrent_requests, 323 | max_batch_size: options.max_batch_size 324 | }; 325 | 326 | send_batch_requests(batch_options, callback); 327 | }; 328 | 329 | /** 330 | import(event, time, properties, callback) 331 | --- 332 | This function sends an event to mixpanel using the import 333 | endpoint. The time argument should be either a Date or Number, 334 | and should signify the time the event occurred. 335 | 336 | It is highly recommended that you specify the distinct_id 337 | property for each event you import, otherwise the events will be 338 | tied to the IP address of the sending machine. 339 | 340 | For more information look at: 341 | https://mixpanel.com/docs/api-documentation/importing-events-older-than-31-days 342 | 343 | event:string the event name 344 | time:date|number the time of the event 345 | properties:object additional event properties to send 346 | callback:function(err:Error) callback is called when the request is 347 | finished or an error occurs 348 | */ 349 | metrics.import = function(event, time, properties, callback) { 350 | if (!properties || typeof properties === "function") { 351 | callback = properties; 352 | properties = {}; 353 | } 354 | 355 | properties.time = ensure_timestamp(time); 356 | 357 | metrics.send_event_request("/import", event, properties, callback); 358 | }; 359 | 360 | /** 361 | import_batch(event_list, options, callback) 362 | --- 363 | This function sends a list of events to mixpanel using the import 364 | endpoint. The format of the event array should be: 365 | 366 | [ 367 | { 368 | "event": "event name", 369 | "properties": { 370 | "time": new Date(), // Number or Date; required for each event 371 | "key": "val", 372 | ... 373 | } 374 | }, 375 | { 376 | "event": "event name", 377 | "properties": { 378 | "time": new Date() // Number or Date; required for each event 379 | } 380 | }, 381 | ... 382 | ] 383 | 384 | See import() for further information about the import endpoint. 385 | 386 | Options: 387 | max_batch_size: the maximum number of events to be transmitted over 388 | the network simultaneously. useful for capping bandwidth 389 | usage. 390 | max_concurrent_requests: the maximum number of concurrent http requests that 391 | can be made to mixpanel; also useful for capping bandwidth. 392 | 393 | N.B.: the Mixpanel API only accepts 50 events per request, so regardless 394 | of max_batch_size, larger lists of events will be chunked further into 395 | groups of 50. 396 | 397 | event_list:array list of event names and properties 398 | options:object optional batch configuration 399 | callback:function(error_list:array) callback is called when the request is 400 | finished or an error occurs 401 | */ 402 | metrics.import_batch = function(event_list, options, callback) { 403 | var batch_options; 404 | 405 | if (typeof(options) === "function" || !options) { 406 | callback = options; 407 | options = {}; 408 | } 409 | batch_options = { 410 | event_list: event_list, 411 | endpoint: "/import", 412 | max_concurrent_requests: options.max_concurrent_requests, 413 | max_batch_size: options.max_batch_size 414 | }; 415 | send_batch_requests(batch_options, callback); 416 | }; 417 | 418 | /** 419 | alias(distinct_id, alias) 420 | --- 421 | This function creates an alias for distinct_id 422 | 423 | For more information look at: 424 | https://mixpanel.com/docs/integration-libraries/using-mixpanel-alias 425 | 426 | distinct_id:string the current identifier 427 | alias:string the future alias 428 | */ 429 | metrics.alias = function(distinct_id, alias, callback) { 430 | var properties = { 431 | distinct_id: distinct_id, 432 | alias: alias 433 | }; 434 | 435 | metrics.track('$create_alias', properties, callback); 436 | }; 437 | 438 | metrics.groups = new MixpanelGroups(metrics); 439 | metrics.people = new MixpanelPeople(metrics); 440 | 441 | /** 442 | set_config(config) 443 | --- 444 | Modifies the mixpanel config 445 | 446 | config:object an object with properties to override in the 447 | mixpanel client config 448 | */ 449 | metrics.set_config = function(config) { 450 | if (config && config.logger !== undefined) { 451 | assert_logger(config.logger); 452 | } 453 | Object.assign(metrics.config, config); 454 | if (config.host) { 455 | // Split host into host and port 456 | const [host, port] = config.host.split(':'); 457 | metrics.config.host = host; 458 | if (port) { 459 | metrics.config.port = Number(port); 460 | } 461 | } 462 | }; 463 | 464 | if (config) { 465 | metrics.set_config(config); 466 | } 467 | 468 | return metrics; 469 | }; 470 | 471 | // module exporting 472 | module.exports = { 473 | init: create_client, 474 | }; 475 | -------------------------------------------------------------------------------- /lib/people.js: -------------------------------------------------------------------------------- 1 | const {merge_modifiers, ProfileHelpers} = require('./profile_helpers'); 2 | 3 | class MixpanelPeople extends ProfileHelpers() { 4 | constructor(mp_instance) { 5 | super(); 6 | this.mixpanel = mp_instance; 7 | this.endpoint = '/engage'; 8 | } 9 | 10 | /** people.set_once(distinct_id, prop, to, modifiers, callback) 11 | --- 12 | The same as people.set but in the words of mixpanel: 13 | mixpanel.people.set_once 14 | 15 | " This method allows you to set a user attribute, only if 16 | it is not currently set. It can be called multiple times 17 | safely, so is perfect for storing things like the first date 18 | you saw a user, or the referrer that brought them to your 19 | website for the first time. " 20 | 21 | */ 22 | set_once(distinct_id, prop, to, modifiers, callback) { 23 | const identifiers = {$distinct_id: distinct_id}; 24 | this._set(prop, to, modifiers, callback, {identifiers, set_once: true}); 25 | } 26 | 27 | /** 28 | people.set(distinct_id, prop, to, modifiers, callback) 29 | --- 30 | set properties on an user record in engage 31 | 32 | usage: 33 | 34 | mixpanel.people.set('bob', 'gender', 'm'); 35 | 36 | mixpanel.people.set('joe', { 37 | 'company': 'acme', 38 | 'plan': 'premium' 39 | }); 40 | */ 41 | set(distinct_id, prop, to, modifiers, callback) { 42 | const identifiers = {$distinct_id: distinct_id}; 43 | this._set(prop, to, modifiers, callback, {identifiers}); 44 | } 45 | 46 | /** 47 | people.increment(distinct_id, prop, by, modifiers, callback) 48 | --- 49 | increment/decrement properties on an user record in engage 50 | 51 | usage: 52 | 53 | mixpanel.people.increment('bob', 'page_views', 1); 54 | 55 | // or, for convenience, if you're just incrementing a counter by 1, you can 56 | // simply do 57 | mixpanel.people.increment('bob', 'page_views'); 58 | 59 | // to decrement a counter, pass a negative number 60 | mixpanel.people.increment('bob', 'credits_left', -1); 61 | 62 | // like mixpanel.people.set(), you can increment multiple properties at once: 63 | mixpanel.people.increment('bob', { 64 | counter1: 1, 65 | counter2: 3, 66 | counter3: -2 67 | }); 68 | */ 69 | increment(distinct_id, prop, by, modifiers, callback) { 70 | // TODO extract to ProfileHelpers 71 | 72 | var $add = {}; 73 | 74 | if (typeof(prop) === 'object') { 75 | if (typeof(by) === 'object') { 76 | callback = modifiers; 77 | modifiers = by; 78 | } else { 79 | callback = by; 80 | } 81 | for (const [key, val] of Object.entries(prop)) { 82 | if (isNaN(parseFloat(val))) { 83 | if (this.mixpanel.config.debug) { 84 | this.mixpanel.config.logger.error( 85 | "Invalid increment value passed to mixpanel.people.increment - must be a number", 86 | {key, value: val} 87 | ); 88 | } 89 | } else { 90 | $add[key] = val; 91 | } 92 | }; 93 | } else { 94 | if (typeof(by) === 'number' || !by) { 95 | by = by || 1; 96 | $add[prop] = by; 97 | if (typeof(modifiers) === 'function') { 98 | callback = modifiers; 99 | } 100 | } else if (typeof(by) === 'function') { 101 | callback = by; 102 | $add[prop] = 1; 103 | } else { 104 | callback = modifiers; 105 | modifiers = (typeof(by) === 'object') ? by : {}; 106 | $add[prop] = 1; 107 | } 108 | } 109 | 110 | var data = { 111 | '$add': $add, 112 | '$token': this.mixpanel.token, 113 | '$distinct_id': distinct_id 114 | }; 115 | 116 | data = merge_modifiers(data, modifiers); 117 | 118 | if (this.mixpanel.config.debug) { 119 | this.mixpanel.config.logger.debug("Sending the following data to Mixpanel (Engage)", { data }); 120 | } 121 | 122 | this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); 123 | } 124 | 125 | /** 126 | people.append(distinct_id, prop, value, modifiers, callback) 127 | --- 128 | Append a value to a list-valued people analytics property. 129 | 130 | usage: 131 | 132 | // append a value to a list, creating it if needed 133 | mixpanel.people.append('bob', 'pages_visited', 'homepage'); 134 | 135 | // like mixpanel.people.set(), you can append multiple properties at once: 136 | mixpanel.people.append('bob', { 137 | list1: 'bob', 138 | list2: 123 139 | }); 140 | */ 141 | append(distinct_id, prop, value, modifiers, callback) { 142 | // TODO extract to ProfileHelpers 143 | 144 | var $append = {}; 145 | 146 | if (typeof(prop) === 'object') { 147 | if (typeof(value) === 'object') { 148 | callback = modifiers; 149 | modifiers = value; 150 | } else { 151 | callback = value; 152 | } 153 | Object.keys(prop).forEach(function(key) { 154 | $append[key] = prop[key]; 155 | }); 156 | } else { 157 | $append[prop] = value; 158 | if (typeof(modifiers) === 'function') { 159 | callback = modifiers; 160 | } 161 | } 162 | 163 | var data = { 164 | '$append': $append, 165 | '$token': this.mixpanel.token, 166 | '$distinct_id': distinct_id 167 | }; 168 | 169 | data = merge_modifiers(data, modifiers); 170 | 171 | if (this.mixpanel.config.debug) { 172 | this.mixpanel.config.logger.debug("Sending the following data to Mixpanel (Engage)", { data }); 173 | } 174 | 175 | this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); 176 | } 177 | 178 | /** 179 | people.track_charge(distinct_id, amount, properties, modifiers, callback) 180 | --- 181 | Record that you have charged the current user a certain 182 | amount of money. 183 | 184 | usage: 185 | 186 | // charge a user $29.99 187 | mixpanel.people.track_charge('bob', 29.99); 188 | 189 | // charge a user $19 on the 1st of february 190 | mixpanel.people.track_charge('bob', 19, { '$time': new Date('feb 1 2012') }); 191 | */ 192 | track_charge(distinct_id, amount, properties, modifiers, callback) { 193 | if (typeof(properties) === 'function' || !properties) { 194 | callback = properties || undefined; 195 | properties = {}; 196 | } else { 197 | if (typeof(modifiers) === 'function' || !modifiers) { 198 | callback = modifiers || undefined; 199 | if (properties.$ignore_time || properties.hasOwnProperty("$ip")) { 200 | modifiers = {}; 201 | Object.keys(properties).forEach(function(key) { 202 | modifiers[key] = properties[key]; 203 | delete properties[key]; 204 | }); 205 | } 206 | } 207 | } 208 | 209 | if (typeof(amount) !== 'number') { 210 | amount = parseFloat(amount); 211 | if (isNaN(amount)) { 212 | this.mixpanel.config.logger.error("Invalid value passed to mixpanel.people.track_charge - must be a number"); 213 | return; 214 | } 215 | } 216 | 217 | properties.$amount = amount; 218 | 219 | if (properties.hasOwnProperty('$time')) { 220 | var time = properties.$time; 221 | if (Object.prototype.toString.call(time) === '[object Date]') { 222 | properties.$time = time.toISOString(); 223 | } 224 | } 225 | 226 | var data = { 227 | '$append': { '$transactions': properties }, 228 | '$token': this.mixpanel.token, 229 | '$distinct_id': distinct_id 230 | }; 231 | 232 | data = merge_modifiers(data, modifiers); 233 | 234 | if (this.mixpanel.config.debug) { 235 | this.mixpanel.config.logger.debug("Sending the following data to Mixpanel (Engage)", { data }); 236 | } 237 | 238 | this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); 239 | } 240 | 241 | /** 242 | people.clear_charges(distinct_id, modifiers, callback) 243 | --- 244 | Clear all the current user's transactions. 245 | 246 | usage: 247 | 248 | mixpanel.people.clear_charges('bob'); 249 | */ 250 | clear_charges(distinct_id, modifiers, callback) { 251 | var data = { 252 | '$set': { '$transactions': [] }, 253 | '$token': this.mixpanel.token, 254 | '$distinct_id': distinct_id 255 | }; 256 | 257 | if (typeof(modifiers) === 'function') { callback = modifiers; } 258 | 259 | data = merge_modifiers(data, modifiers); 260 | 261 | if (this.mixpanel.config.debug) { 262 | this.mixpanel.config.logger.debug("Clearing this user's charges", { '$distinct_id': distinct_id }); 263 | } 264 | 265 | this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); 266 | } 267 | 268 | /** 269 | people.delete_user(distinct_id, modifiers, callback) 270 | --- 271 | delete an user record in engage 272 | 273 | usage: 274 | 275 | mixpanel.people.delete_user('bob'); 276 | */ 277 | delete_user(distinct_id, modifiers, callback) { 278 | const identifiers = {$distinct_id: distinct_id}; 279 | this._delete_profile({identifiers, modifiers, callback}); 280 | } 281 | 282 | /** 283 | people.remove(distinct_id, data, modifiers, callback) 284 | --- 285 | remove a value from a list-valued user profile property. 286 | 287 | usage: 288 | 289 | mixpanel.people.remove('bob', {'browsers': 'firefox'}); 290 | 291 | mixpanel.people.remove('bob', {'browsers': 'chrome', 'os': 'linux'}); 292 | */ 293 | remove(distinct_id, data, modifiers, callback) { 294 | const identifiers = {'$distinct_id': distinct_id}; 295 | this._remove({identifiers, data, modifiers, callback}) 296 | } 297 | 298 | /** 299 | people.union(distinct_id, data, modifiers, callback) 300 | --- 301 | merge value(s) into a list-valued people analytics property. 302 | 303 | usage: 304 | 305 | mixpanel.people.union('bob', {'browsers': 'firefox'}); 306 | 307 | mixpanel.people.union('bob', {'browsers': ['chrome'], os: ['linux']}); 308 | */ 309 | union(distinct_id, data, modifiers, callback) { 310 | const identifiers = {$distinct_id: distinct_id}; 311 | this._union({identifiers, data, modifiers, callback}); 312 | } 313 | 314 | /** 315 | people.unset(distinct_id, prop, modifiers, callback) 316 | --- 317 | delete a property on an user record in engage 318 | 319 | usage: 320 | 321 | mixpanel.people.unset('bob', 'page_views'); 322 | 323 | mixpanel.people.unset('bob', ['page_views', 'last_login']); 324 | */ 325 | unset(distinct_id, prop, modifiers, callback) { 326 | const identifiers = {$distinct_id: distinct_id}; 327 | this._unset({identifiers, prop, modifiers, callback}); 328 | } 329 | }; 330 | 331 | exports.MixpanelPeople = MixpanelPeople; 332 | -------------------------------------------------------------------------------- /lib/profile_helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mixin with profile-related helpers (for people and groups) 3 | */ 4 | 5 | const util = require('util'); 6 | const {ensure_timestamp} = require('./utils'); 7 | 8 | function merge_modifiers(data, modifiers) { 9 | if (modifiers) { 10 | if (modifiers.$ignore_alias) { 11 | data.$ignore_alias = modifiers.$ignore_alias; 12 | } 13 | if (modifiers.$ignore_time) { 14 | data.$ignore_time = modifiers.$ignore_time; 15 | } 16 | if (modifiers.hasOwnProperty("$ip")) { 17 | data.$ip = modifiers.$ip; 18 | } 19 | if (modifiers.hasOwnProperty("$time")) { 20 | data.$time = ensure_timestamp(modifiers.$time); 21 | } 22 | if (modifiers.hasOwnProperty("$latitude") && modifiers.hasOwnProperty('$longitude')) { 23 | data.$latitude = modifiers.$latitude; 24 | data.$longitude = modifiers.$longitude; 25 | } 26 | } 27 | return data; 28 | }; 29 | exports.merge_modifiers = merge_modifiers; 30 | 31 | exports.ProfileHelpers = (Base = Object) => class extends Base { 32 | get token() { 33 | return this.mixpanel.token; 34 | } 35 | 36 | get config() { 37 | return this.mixpanel.config; 38 | } 39 | 40 | _set(prop, to, modifiers, callback, {identifiers, set_once = false}) { 41 | let $set = {}; 42 | 43 | if (typeof(prop) === 'object') { 44 | if (typeof(to) === 'object') { 45 | callback = modifiers; 46 | modifiers = to; 47 | } else { 48 | callback = to; 49 | } 50 | $set = prop; 51 | } else { 52 | $set[prop] = to; 53 | if (typeof(modifiers) === 'function' || !modifiers) { 54 | callback = modifiers; 55 | } 56 | } 57 | 58 | let data = { 59 | '$token': this.token, 60 | ...identifiers, 61 | }; 62 | 63 | const set_key = set_once ? "$set_once" : "$set"; 64 | data[set_key] = $set; 65 | 66 | if ('ip' in $set) { 67 | data.$ip = $set.ip; 68 | delete $set.ip; 69 | } 70 | 71 | if ($set.$ignore_time) { 72 | data.$ignore_time = $set.$ignore_time; 73 | delete $set.$ignore_time; 74 | } 75 | 76 | data = merge_modifiers(data, modifiers); 77 | 78 | if (this.config.debug) { 79 | this.mixpanel.config.logger.debug(`Sending the following data to Mixpanel (${this.endpoint})`, { data }); 80 | } 81 | 82 | this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); 83 | } 84 | 85 | _delete_profile({identifiers, modifiers, callback}){ 86 | let data = { 87 | '$delete': '', 88 | '$token': this.token, 89 | ...identifiers, 90 | }; 91 | 92 | if (typeof(modifiers) === 'function') { callback = modifiers; } 93 | 94 | data = merge_modifiers(data, modifiers); 95 | 96 | if (this.config.debug) { 97 | this.mixpanel.config.logger.debug('Deleting profile', { identifiers }); 98 | } 99 | 100 | this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); 101 | } 102 | 103 | _remove({identifiers, data, modifiers, callback}) { 104 | let $remove = {}; 105 | 106 | if (typeof(data) !== 'object' || util.isArray(data)) { 107 | if (this.config.debug) { 108 | this.mixpanel.config.logger.error("Invalid value passed to #remove - data must be an object with scalar values"); 109 | } 110 | return; 111 | } 112 | 113 | for (const [key, val] of Object.entries(data)) { 114 | if (typeof(val) === 'string' || typeof(val) === 'number') { 115 | $remove[key] = val; 116 | } else { 117 | if (this.config.debug) { 118 | this.mixpanel.config.logger.error( 119 | "Invalid argument passed to #remove - values must be scalar", 120 | { key, value: val } 121 | ); 122 | } 123 | return; 124 | } 125 | } 126 | 127 | if (Object.keys($remove).length === 0) { 128 | return; 129 | } 130 | 131 | data = { 132 | '$remove': $remove, 133 | '$token': this.token, 134 | ...identifiers 135 | }; 136 | 137 | if (typeof(modifiers) === 'function') { 138 | callback = modifiers; 139 | } 140 | 141 | data = merge_modifiers(data, modifiers); 142 | 143 | if (this.config.debug) { 144 | this.mixpanel.config.logger.debug( 145 | `Sending the following data to Mixpanel (${this.endpoint})`, 146 | { data } 147 | ); 148 | } 149 | 150 | this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); 151 | } 152 | 153 | _union({identifiers, data, modifiers, callback}) { 154 | let $union = {}; 155 | 156 | if (typeof(data) !== 'object' || util.isArray(data)) { 157 | if (this.config.debug) { 158 | this.mixpanel.config.logger.error("Invalid value passed to #union - data must be an object with scalar or array values"); 159 | } 160 | return; 161 | } 162 | 163 | for (const [key, val] of Object.entries(data)) { 164 | if (util.isArray(val)) { 165 | var merge_values = val.filter(function(v) { 166 | return typeof(v) === 'string' || typeof(v) === 'number'; 167 | }); 168 | if (merge_values.length > 0) { 169 | $union[key] = merge_values; 170 | } 171 | } else if (typeof(val) === 'string' || typeof(val) === 'number') { 172 | $union[key] = [val]; 173 | } else { 174 | if (this.config.debug) { 175 | this.mixpanel.config.logger.error( 176 | "Invalid argument passed to #union - values must be a scalar value or array", 177 | { key, value: val } 178 | ); 179 | } 180 | } 181 | } 182 | 183 | if (Object.keys($union).length === 0) { 184 | return; 185 | } 186 | 187 | data = { 188 | '$union': $union, 189 | '$token': this.token, 190 | ...identifiers, 191 | }; 192 | 193 | if (typeof(modifiers) === 'function') { 194 | callback = modifiers; 195 | } 196 | 197 | data = merge_modifiers(data, modifiers); 198 | 199 | if (this.config.debug) { 200 | this.mixpanel.config.logger.debug( 201 | `Sending the following data to Mixpanel (${this.endpoint})`, 202 | { data } 203 | ); 204 | } 205 | 206 | this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); 207 | } 208 | 209 | _unset({identifiers, prop, modifiers, callback}){ 210 | let $unset = []; 211 | 212 | if (util.isArray(prop)) { 213 | $unset = prop; 214 | } else if (typeof(prop) === 'string') { 215 | $unset = [prop]; 216 | } else { 217 | if (this.config.debug) { 218 | this.mixpanel.config.logger.error( 219 | "Invalid argument passed to #unset - must be a string or array", 220 | { prop } 221 | ); 222 | } 223 | return; 224 | } 225 | 226 | let data = { 227 | '$unset': $unset, 228 | '$token': this.token, 229 | ...identifiers, 230 | }; 231 | 232 | if (typeof(modifiers) === 'function') { 233 | callback = modifiers; 234 | } 235 | 236 | data = merge_modifiers(data, modifiers); 237 | 238 | if (this.config.debug) { 239 | this.mixpanel.config.logger.debug( 240 | `Sending the following data to Mixpanel (${this.endpoint})`, 241 | { data } 242 | ); 243 | } 244 | 245 | this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); 246 | } 247 | }; 248 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * helper to wait for all callbacks to complete; similar to `Promise.all` 3 | * exposed to metrics object for unit tests 4 | * @param {Array} requests 5 | * @param {Function} handler 6 | * @param {Function} callback 7 | */ 8 | exports.async_all = function(requests, handler, callback) { 9 | var total = requests.length, 10 | errors = null, 11 | results = [], 12 | done = function (err, result) { 13 | if (err) { 14 | // errors are `null` unless there is an error, which allows for promisification 15 | errors = errors || []; 16 | errors.push(err); 17 | } 18 | results.push(result); 19 | if (--total === 0) { 20 | callback(errors, results) 21 | } 22 | }; 23 | 24 | if (total === 0) { 25 | callback(errors, results); 26 | } else { 27 | for(var i = 0, l = requests.length; i < l; i++) { 28 | handler(requests[i], done); 29 | } 30 | } 31 | }; 32 | 33 | /** 34 | * Validate type of time property, and convert to Unix timestamp if necessary 35 | * @param {Date|number} time - value to check 36 | * @returns {number} Unix timestamp 37 | */ 38 | exports.ensure_timestamp = function(time) { 39 | if (!(time instanceof Date || typeof time === "number")) { 40 | throw new Error("`time` property must be a Date or Unix timestamp and is only required for `import` endpoint"); 41 | } 42 | return time instanceof Date ? time.getTime() : time; 43 | }; 44 | 45 | /** 46 | * Asserts that the provided logger object is valid 47 | * @param {CustomLogger} logger - The logger object to be validated 48 | * @throws {TypeError} If the logger object is not a valid Logger object or 49 | * if it is missing any of the required methods 50 | */ 51 | exports.assert_logger = function(logger) { 52 | if (typeof logger !== 'object') { 53 | throw new TypeError(`"logger" must be a valid Logger object`); 54 | } 55 | 56 | ['trace', 'debug', 'info', 'warn', 'error'].forEach((method) => { 57 | if (typeof logger[method] !== 'function') { 58 | throw new TypeError(`Logger object missing "${method}" method`); 59 | } 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mixpanel", 3 | "description": "A simple server-side API for mixpanel", 4 | "keywords": [ 5 | "mixpanel", 6 | "analytics", 7 | "api", 8 | "stats" 9 | ], 10 | "version": "0.18.1", 11 | "homepage": "https://github.com/mixpanel/mixpanel-node", 12 | "author": "Carl Sverre", 13 | "license": "MIT", 14 | "main": "lib/mixpanel-node", 15 | "directories": { 16 | "lib": "lib" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+ssh://git@github.com/mixpanel/mixpanel-node.git" 21 | }, 22 | "engines": { 23 | "node": ">=10.0" 24 | }, 25 | "scripts": { 26 | "test": "vitest" 27 | }, 28 | "types": "./lib/mixpanel-node.d.ts", 29 | "devDependencies": { 30 | "@vitest/coverage-v8": "^1.6.0", 31 | "proxyquire": "^1.7.11", 32 | "vitest": "^1.6.0" 33 | }, 34 | "dependencies": { 35 | "https-proxy-agent": "5.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Mixpanel-node 2 | ============= 3 | ![Build Status](https://github.com/mixpanel/mixpanel-node/actions/workflows/tests.yml/badge.svg) 4 | 5 | This library provides many of the features in the official JavaScript mixpanel library. It is easy to use, and fully async. It is intended to be used on the server (it is not a client module). The in-browser client library is available 6 | at [https://github.com/mixpanel/mixpanel-js](https://github.com/mixpanel/mixpanel-js). 7 | 8 | Installation 9 | ------------ 10 | 11 | npm install mixpanel 12 | 13 | Quick Start 14 | ----------- 15 | 16 | ```javascript 17 | // grab the Mixpanel factory 18 | var Mixpanel = require('mixpanel'); 19 | 20 | // create an instance of the mixpanel client 21 | var mixpanel = Mixpanel.init(''); 22 | 23 | // initialize mixpanel client configured to communicate over http instead of https 24 | var mixpanel = Mixpanel.init('', { 25 | protocol: 'http', 26 | }); 27 | 28 | // turn off keepAlive (reestablish connection on each request) 29 | var mixpanel = Mixpanel.init('', { 30 | keepAlive: false, 31 | }); 32 | 33 | // pass the custom logger (default is console) 34 | var mixpanel = Mixpanel.init('', { 35 | debug: true, 36 | logger: pinoLogger, // or bunyan, or any other logger that implements the same interface 37 | }); 38 | 39 | // track an event with optional properties 40 | mixpanel.track('my event', { 41 | distinct_id: 'some unique client id', 42 | as: 'many', 43 | properties: 'as', 44 | you: 'want' 45 | }); 46 | mixpanel.track('played_game'); 47 | 48 | // set an IP address to get automatic geolocation info 49 | mixpanel.track('my event', {ip: '127.0.0.1'}); 50 | 51 | // track an event with a specific timestamp (up to 5 days old; 52 | // use mixpanel.import() for older events) 53 | mixpanel.track('timed event', {time: new Date()}); 54 | 55 | // create or update a user in Mixpanel Engage 56 | mixpanel.people.set('billybob', { 57 | $first_name: 'Billy', 58 | $last_name: 'Bob', 59 | $created: (new Date('jan 1 2013')).toISOString(), 60 | plan: 'premium', 61 | games_played: 1, 62 | points: 0 63 | }); 64 | 65 | // create or update a user in Mixpanel Engage without altering $last_seen 66 | // - pass option $ignore_time: true to prevent the $last_seen property from being updated 67 | mixpanel.people.set('billybob', { 68 | plan: 'premium', 69 | games_played: 1 70 | }, { 71 | $ignore_time: true 72 | }); 73 | 74 | // set a user profile's IP address to get automatic geolocation info 75 | mixpanel.people.set('billybob', { 76 | plan: 'premium', 77 | games_played: 1 78 | }, { 79 | $ip: '127.0.0.1' 80 | }); 81 | 82 | // set a user profile's latitude and longitude to get automatic geolocation info 83 | mixpanel.people.set('billybob', { 84 | plan: 'premium', 85 | games_played: 1 86 | }, { 87 | $latitude: 40.7127753, 88 | $longitude: -74.0059728 89 | }); 90 | 91 | // set a single property on a user 92 | mixpanel.people.set('billybob', 'plan', 'free'); 93 | 94 | // set a single property on a user, don't override 95 | mixpanel.people.set_once('billybob', 'first_game_play', (new Date('jan 1 2013')).toISOString()); 96 | 97 | // increment a numeric property 98 | mixpanel.people.increment('billybob', 'games_played'); 99 | 100 | // increment a numeric property by a different amount 101 | mixpanel.people.increment('billybob', 'points', 15); 102 | 103 | // increment multiple properties 104 | mixpanel.people.increment('billybob', {'points': 10, 'games_played': 1}); 105 | 106 | // append value to a list 107 | mixpanel.people.append('billybob', 'awards', 'Great Player'); 108 | 109 | // append multiple values to a list 110 | mixpanel.people.append('billybob', {'awards': 'Great Player', 'levels_finished': 'Level 4'}); 111 | 112 | // merge value to a list (ignoring duplicates) 113 | mixpanel.people.union('billybob', {'browsers': 'ie'}); 114 | 115 | // merge multiple values to a list (ignoring duplicates) 116 | mixpanel.people.union('billybob', {'browsers': ['ie', 'chrome']}); 117 | 118 | 119 | // record a transaction for revenue analytics 120 | mixpanel.people.track_charge('billybob', 39.99); 121 | 122 | // clear a users transaction history 123 | mixpanel.people.clear_charges('billybob'); 124 | 125 | // delete a user 126 | mixpanel.people.delete_user('billybob'); 127 | 128 | // delete a user in Mixpanel Engage without altering $last_seen or resolving aliases 129 | // - pass option $ignore_time: true to prevent the $last_seen property from being updated 130 | // (useful if you subsequently re-import data for the same distinct ID) 131 | mixpanel.people.delete_user('billybob', {$ignore_time: true, $ignore_alias: true}); 132 | 133 | // Create an alias for an existing distinct id 134 | mixpanel.alias('distinct_id', 'your_alias'); 135 | 136 | // all functions that send data to mixpanel take an optional 137 | // callback as the last argument 138 | mixpanel.track('test', function(err) { if (err) throw err; }); 139 | 140 | // track multiple events at once 141 | mixpanel.track_batch([ 142 | { 143 | event: 'recent event', 144 | properties: { 145 | time: new Date(), 146 | distinct_id: 'billybob', 147 | gender: 'male' 148 | } 149 | }, 150 | { 151 | event: 'another recent event', 152 | properties: { 153 | distinct_id: 'billybob', 154 | color: 'red' 155 | } 156 | } 157 | ]); 158 | 159 | // import an old event 160 | var mixpanel_importer = Mixpanel.init('valid mixpanel token', { 161 | secret: 'valid api secret for project' 162 | }); 163 | 164 | // needs to be in the system once for it to show up in the interface 165 | mixpanel_importer.track('old event', { gender: '' }); 166 | 167 | mixpanel_importer.import('old event', new Date(2012, 4, 20, 12, 34, 56), { 168 | distinct_id: 'billybob', 169 | gender: 'male' 170 | }); 171 | 172 | // import multiple events at once 173 | mixpanel_importer.import_batch([ 174 | { 175 | event: 'old event', 176 | properties: { 177 | time: new Date(2012, 4, 20, 12, 34, 56), 178 | distinct_id: 'billybob', 179 | gender: 'male' 180 | } 181 | }, 182 | { 183 | event: 'another old event', 184 | properties: { 185 | time: new Date(2012, 4, 21, 11, 33, 55), 186 | distinct_id: 'billybob', 187 | color: 'red' 188 | } 189 | } 190 | ]); 191 | ``` 192 | 193 | FAQ 194 | --- 195 | **Where is `mixpanel.identify()`?** 196 | 197 | `mixpanel-node` is a server-side library, optimized for stateless shared usage; e.g., 198 | in a web application, the same mixpanel instance is used across requests for all users. 199 | Rather than setting a `distinct_id` through `identify()` calls like Mixpanel client-side 200 | libraries (where a single Mixpanel instance is tied to a single user), this library 201 | requires you to pass the `distinct_id` with every tracking call. See 202 | https://github.com/mixpanel/mixpanel-node/issues/13. 203 | 204 | **How do I get or set superproperties?** 205 | 206 | See the previous answer: the library does not maintain user state internally and so has 207 | no concept of superproperties for individual users. If you wish to preserve properties 208 | for users between requests, you will need to load these properties from a source specific 209 | to your app (e.g., your session store or database) and pass them explicitly with each 210 | tracking call. 211 | 212 | 213 | Tests 214 | ----- 215 | 216 | # in the mixpanel directory 217 | npm install 218 | npm test 219 | 220 | Alternative Clients and Related Tools 221 | ------------------------------------- 222 | 223 | - [Mixpanel-CLI](https://github.com/FGRibreau/mixpanel-cli) - CLI for Mixpanel API (currently only supports tracking functions) 224 | - [Mixpanel Data Export](https://github.com/michaelcarter/mixpanel-data-export-js) - Supports various query and data-management APIs; runs in both Node.js and browser 225 | - [Mixpanel Data Export (strawbrary)](https://github.com/strawbrary/mixpanel-data-export-js) - Fork of previous library, optimized for Node.js with support for streaming large raw exports 226 | 227 | Attribution/Credits 228 | ------------------- 229 | 230 | Heavily inspired by the original js library copyright Mixpanel, Inc. 231 | (http://mixpanel.com/) 232 | 233 | Copyright (c) 2014-21 Mixpanel 234 | Original Library Copyright (c) 2012-14 Carl Sverre 235 | 236 | Contributions from: 237 | - [Andres Gottlieb](https://github.com/andresgottlieb) 238 | - [Ken Perkins](https://github.com/kenperkins) 239 | - [Nathan Rajlich](https://github.com/TooTallNate) 240 | - [Thomas Watson Steen](https://github.com/watson) 241 | - [Gabor Ratky](https://github.com/rgabo) 242 | - [wwlinx](https://github.com/wwlinx) 243 | - [PierrickP](https://github.com/PierrickP) 244 | - [lukapril](https://github.com/lukapril) 245 | - [sandinmyjoints](https://github.com/sandinmyjoints) 246 | - [Jyrki Laurila](https://github.com/jylauril) 247 | - [Zeevl](https://github.com/zeevl) 248 | - [Tobias Baunbæk](https://github.com/freeall) 249 | - [Eduardo Sorribas](https://github.com/sorribas) 250 | - [Nick Chang](https://github.com/maeldur) 251 | - [Michael G](https://github.com/gmichael225) 252 | - [Tejas Manohar](https://github.com/tejasmanohar) 253 | - [Eelke Boezeman](https://github.com/godspeedelbow) 254 | - [Jim Thomas](https://github.com/Left47) 255 | - [Frank Chiang](https://github.com/chiangf) 256 | - [Morgan Croney](https://github.com/cruzanmo) 257 | - [Cole Furfaro-Strode](https://github.com/colestrode) 258 | - [Jonas Hermsmeier](https://github.com/jhermsmeier) 259 | - [Marko Klopets](https://github.com/mklopets) 260 | - [Cameron Diver](https://github.com/CameronDiver) 261 | - [veerabio](https://github.com/veerabio) 262 | - [Will Neild](https://github.com/wneild) 263 | - [Elijah Insua](https://github.com/tmpvar) 264 | - [Arsal Imam](https://github.com/ArsalImam) 265 | - [Aleksei Iatsiuk](https://github.com/iatsiuk) 266 | - [Vincent Giersch](https://github.com/gierschv) 267 | 268 | License 269 | ------------------- 270 | 271 | Released under the MIT license. See file called LICENSE for more 272 | details. 273 | -------------------------------------------------------------------------------- /test/alias.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require('../lib/mixpanel-node'); 2 | 3 | describe('alias', () => { 4 | let mixpanel; 5 | let sendRequestMock; 6 | beforeEach(() => { 7 | mixpanel = Mixpanel.init('token', { key: 'key' }); 8 | vi.spyOn(mixpanel, 'send_request'); 9 | return () => { 10 | mixpanel.send_request.mockRestore(); 11 | }; 12 | }); 13 | 14 | it("calls send_request with correct endpoint and data", () => { 15 | var alias = "test", 16 | distinct_id = "old_id", 17 | expected_endpoint = "/track", 18 | expected_data = { 19 | event: '$create_alias', 20 | properties: expect.objectContaining({ 21 | distinct_id: distinct_id, 22 | alias: alias, 23 | token: 'token', 24 | }), 25 | }; 26 | 27 | mixpanel.alias(distinct_id, alias); 28 | 29 | expect(mixpanel.send_request).toHaveBeenCalledWith( 30 | expect.objectContaining({ 31 | endpoint: expected_endpoint, 32 | data: expected_data, 33 | }), 34 | undefined, 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require('../lib/mixpanel-node'); 2 | 3 | describe('config', () => { 4 | let mixpanel; 5 | beforeEach(() => { 6 | mixpanel = Mixpanel.init('asjdf'); 7 | }) 8 | it("is set to correct defaults", () => { 9 | expect(mixpanel.config).toEqual({ 10 | test: false, 11 | debug: false, 12 | verbose: false, 13 | host: 'api.mixpanel.com', 14 | protocol: 'https', 15 | path: '', 16 | keepAlive: true, 17 | geolocate: false, 18 | logger: console, 19 | }); 20 | }); 21 | 22 | it("is modified by set_config", () => { 23 | expect(mixpanel.config.test).toBe(false); 24 | 25 | mixpanel.set_config({ test: true }); 26 | 27 | expect(mixpanel.config.test).toBe(true); 28 | }); 29 | 30 | it("can be set during init", () => { 31 | var mp = Mixpanel.init('token', { test: true }); 32 | 33 | expect(mp.config.test).toBe(true); 34 | }); 35 | 36 | it("host config is split into host and port", () => { 37 | const exampleHost = 'api.example.com'; 38 | const examplePort = 70; 39 | const hostWithoutPortConfig = Mixpanel.init('token', {host: exampleHost}).config; 40 | expect(hostWithoutPortConfig.port).toEqual(undefined); 41 | expect(hostWithoutPortConfig.host).toEqual(exampleHost); 42 | 43 | const hostWithPortConfig = Mixpanel.init('token', {host: `${exampleHost}:${examplePort}`}).config; 44 | expect(hostWithPortConfig.port).toBe(examplePort); 45 | expect(hostWithPortConfig.host).toBe(exampleHost); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/groups.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require('../lib/mixpanel-node'); 2 | const {create_group_funcs} = require('../lib/groups'); 3 | const {create_profile_helpers} = require('../lib/profile_helpers'); 4 | 5 | 6 | 7 | describe("groups", () => { 8 | const endpoint = '/groups'; 9 | const group_key = 'company'; 10 | const group_id = 'Acme Inc.'; 11 | const token = 'token'; 12 | let mixpanel; 13 | beforeEach(() => { 14 | mixpanel = Mixpanel.init(token); 15 | vi.spyOn(mixpanel, 'send_request'); 16 | 17 | return () => { 18 | mixpanel.send_request.mockRestore(); 19 | } 20 | }); 21 | 22 | // shared test case 23 | const test_send_request_args = function(func, {args, expected, use_modifiers, use_callback} = {}) { 24 | let expected_data = {$token: token, $group_key: group_key, $group_id: group_id, ...expected}; 25 | let callback; 26 | 27 | args = [group_key, group_id, ...(args ? args : [])]; 28 | 29 | if (use_modifiers) { 30 | let modifiers = { 31 | '$ignore_alias': true, 32 | '$ignore_time': true, 33 | '$ip': '1.2.3.4', 34 | '$time': 1234567890 35 | }; 36 | Object.assign(expected_data, modifiers); 37 | args.push(modifiers); 38 | } 39 | if (use_callback) { 40 | callback = function() {}; 41 | args.push(callback); 42 | } 43 | 44 | mixpanel.groups[func](...args); 45 | 46 | const expectedSendRequestArgs = [ 47 | { method: 'GET', endpoint, data: expected_data }, 48 | use_callback ? callback : undefined, 49 | ]; 50 | expect(mixpanel.send_request).toHaveBeenCalledWith(...expectedSendRequestArgs) 51 | }; 52 | 53 | describe("_set", () => { 54 | it("handles set_once correctly", () => { 55 | test_send_request_args('set_once', { 56 | args: ['key1', 'val1'], 57 | expected: {$set_once: {'key1': 'val1'}}, 58 | }); 59 | }); 60 | 61 | it("calls send_request with correct endpoint and data", () => { 62 | test_send_request_args('set', { 63 | args: ['key1', 'val1'], 64 | expected: {$set: {'key1': 'val1'}}, 65 | }); 66 | }); 67 | 68 | it("supports being called with a property object", () => { 69 | test_send_request_args('set', { 70 | args: [{'key1': 'val1', 'key2': 'val2'}], 71 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 72 | }); 73 | }); 74 | 75 | it("supports being called with a property object (set_once)", () => { 76 | test_send_request_args('set_once', { 77 | args: [{'key1': 'val1', 'key2': 'val2'}], 78 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 79 | }); 80 | }); 81 | 82 | it("supports being called with a modifiers argument", () => { 83 | test_send_request_args('set', { 84 | args: ['key1', 'val1'], 85 | expected: {$set: {'key1': 'val1'}}, 86 | use_modifiers: true, 87 | }); 88 | }); 89 | 90 | it("supports being called with a modifiers argument (set_once)", () => { 91 | test_send_request_args('set_once', { 92 | args: ['key1', 'val1'], 93 | expected: {$set_once: {'key1': 'val1'}}, 94 | use_modifiers: true, 95 | }); 96 | }); 97 | 98 | it("supports being called with a properties object and a modifiers argument", () => { 99 | test_send_request_args('set', { 100 | args: [{'key1': 'val1', 'key2': 'val2'}], 101 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 102 | use_modifiers: true, 103 | }); 104 | }); 105 | 106 | it("supports being called with a properties object and a modifiers argument (set_once)", () => { 107 | test_send_request_args('set_once', { 108 | args: [{'key1': 'val1', 'key2': 'val2'}], 109 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 110 | use_modifiers: true, 111 | }); 112 | }); 113 | 114 | it("handles the ip property in a property object properly", () => { 115 | test_send_request_args('set', { 116 | args: [{'ip': '1.2.3.4', 'key1': 'val1', 'key2': 'val2'}], 117 | expected: { 118 | $ip: '1.2.3.4', 119 | $set: {'key1': 'val1', 'key2': 'val2'}, 120 | }, 121 | }); 122 | }); 123 | 124 | it("handles the $ignore_time property in a property object properly", () => { 125 | test_send_request_args('set', { 126 | args: [{'$ignore_time': true, 'key1': 'val1', 'key2': 'val2'}], 127 | expected: { 128 | $ignore_time: true, 129 | $set: {'key1': 'val1', 'key2': 'val2'}, 130 | }, 131 | }); 132 | }); 133 | 134 | it("supports being called with a callback", () => { 135 | test_send_request_args('set', { 136 | args: ['key1', 'val1'], 137 | expected: {$set: {'key1': 'val1'}}, 138 | use_callback: true, 139 | }); 140 | }); 141 | 142 | it("supports being called with a callback (set_once)", () => { 143 | test_send_request_args('set_once', { 144 | args: ['key1', 'val1'], 145 | expected: {$set_once: {'key1': 'val1'}}, 146 | use_callback: true, 147 | }); 148 | }); 149 | 150 | it("supports being called with a properties object and a callback", () => { 151 | test_send_request_args('set', { 152 | args: [{'key1': 'val1', 'key2': 'val2'}], 153 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 154 | use_callback: true, 155 | }); 156 | }); 157 | 158 | it("supports being called with a properties object and a callback (set_once)", () => { 159 | test_send_request_args('set_once', { 160 | args: [{'key1': 'val1', 'key2': 'val2'}], 161 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 162 | use_callback: true, 163 | }); 164 | }); 165 | 166 | it("supports being called with a modifiers argument and a callback", () => { 167 | test_send_request_args('set', { 168 | args: ['key1', 'val1'], 169 | expected: {$set: {'key1': 'val1'}}, 170 | use_callback: true, 171 | use_modifiers: true, 172 | }); 173 | }); 174 | 175 | it("supports being called with a modifiers argument and a callback (set_once)", () => { 176 | test_send_request_args('set_once', { 177 | args: ['key1', 'val1'], 178 | expected: {$set_once: {'key1': 'val1'}}, 179 | use_callback: true, 180 | use_modifiers: true, 181 | }); 182 | }); 183 | 184 | it("supports being called with a properties object, a modifiers argument and a callback", () => { 185 | test_send_request_args('set', { 186 | args: [{'key1': 'val1', 'key2': 'val2'}], 187 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 188 | use_callback: true, 189 | use_modifiers: true, 190 | }); 191 | }); 192 | 193 | it("supports being called with a properties object, a modifiers argument and a callback (set_once)", () => { 194 | test_send_request_args('set_once', { 195 | args: [{'key1': 'val1', 'key2': 'val2'}], 196 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 197 | use_callback: true, 198 | use_modifiers: true, 199 | }); 200 | }); 201 | }); 202 | 203 | describe("delete_group", () => { 204 | it("calls send_request with correct endpoint and data", () => { 205 | test_send_request_args('delete_group', { 206 | expected: {$delete: ''}, 207 | }); 208 | }); 209 | 210 | it("supports being called with a modifiers argument", () => { 211 | test_send_request_args('delete_group', { 212 | expected: {$delete: ''}, 213 | use_modifiers: true, 214 | }); 215 | }); 216 | 217 | it("supports being called with a callback", () => { 218 | test_send_request_args('delete_group', { 219 | expected: {$delete: ''}, 220 | use_callback: true, 221 | }); 222 | }); 223 | 224 | it("supports being called with a modifiers argument and a callback", () => { 225 | test_send_request_args('delete_group', { 226 | expected: {$delete: ''}, 227 | use_callback: true, 228 | use_modifiers: true, 229 | }); 230 | }); 231 | }); 232 | 233 | describe("remove", () => { 234 | it("calls send_request with correct endpoint and data", () => { 235 | test_send_request_args('remove', { 236 | args: [{'key1': 'value1', 'key2': 'value2'}], 237 | expected: {$remove: {'key1': 'value1', 'key2': 'value2'}}, 238 | }); 239 | }); 240 | 241 | it("errors on non-scalar argument types", () => { 242 | mixpanel.groups.remove(group_key, group_id, {'key1': ['value1']}); 243 | mixpanel.groups.remove(group_key, group_id, {key1: {key: 'val'}}); 244 | mixpanel.groups.remove(group_key, group_id, 1231241.123); 245 | mixpanel.groups.remove(group_key, group_id, [5]); 246 | mixpanel.groups.remove(group_key, group_id, {key1: function() {}}); 247 | mixpanel.groups.remove(group_key, group_id, {key1: [function() {}]}); 248 | 249 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 250 | }); 251 | 252 | it("supports being called with a modifiers argument", () => { 253 | test_send_request_args('remove', { 254 | args: [{'key1': 'value1'}], 255 | expected: {$remove: {'key1': 'value1'}}, 256 | use_modifiers: true, 257 | }); 258 | }); 259 | 260 | it("supports being called with a callback", () => { 261 | test_send_request_args('remove', { 262 | args: [{'key1': 'value1'}], 263 | expected: {$remove: {'key1': 'value1'}}, 264 | use_callback: true, 265 | }); 266 | }); 267 | 268 | it("supports being called with a modifiers argument and a callback", () => { 269 | test_send_request_args('remove', { 270 | args: [{'key1': 'value1'}], 271 | expected: {$remove: {'key1': 'value1'}}, 272 | use_callback: true, 273 | use_modifiers: true, 274 | }); 275 | }); 276 | }); 277 | 278 | describe("union", () => { 279 | it("calls send_request with correct endpoint and data", () => { 280 | test_send_request_args('union', { 281 | args: [{'key1': ['value1', 'value2']}], 282 | expected: {$union: {'key1': ['value1', 'value2']}}, 283 | }); 284 | }); 285 | 286 | it("supports being called with a scalar value", () => { 287 | test_send_request_args('union', { 288 | args: [{'key1': 'value1'}], 289 | expected: {$union: {'key1': ['value1']}}, 290 | }); 291 | }); 292 | 293 | it("errors on other argument types", () => { 294 | mixpanel.groups.union(group_key, group_id, {key1: {key: 'val'}}); 295 | mixpanel.groups.union(group_key, group_id, 1231241.123); 296 | mixpanel.groups.union(group_key, group_id, [5]); 297 | mixpanel.groups.union(group_key, group_id, {key1: function() {}}); 298 | mixpanel.groups.union(group_key, group_id, {key1: [function() {}]}); 299 | 300 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 301 | }); 302 | 303 | it("supports being called with a modifiers argument", () => { 304 | test_send_request_args('union', { 305 | args: [{'key1': ['value1', 'value2']}], 306 | expected: {$union: {'key1': ['value1', 'value2']}}, 307 | use_modifiers: true, 308 | }); 309 | }); 310 | 311 | it("supports being called with a callback", () => { 312 | test_send_request_args('union', { 313 | args: [{'key1': ['value1', 'value2']}], 314 | expected: {$union: {'key1': ['value1', 'value2']}}, 315 | use_callback: true, 316 | }); 317 | }); 318 | 319 | it("supports being called with a modifiers argument and a callback", () => { 320 | test_send_request_args('union', { 321 | args: [{'key1': ['value1', 'value2']}], 322 | expected: {$union: {'key1': ['value1', 'value2']}}, 323 | use_callback: true, 324 | use_modifiers: true, 325 | }); 326 | }); 327 | }); 328 | 329 | describe("unset", () => { 330 | it("calls send_request with correct endpoint and data", () => { 331 | test_send_request_args('unset', { 332 | args: ['key1'], 333 | expected: {$unset: ['key1']}, 334 | }); 335 | }); 336 | 337 | it("supports being called with a property array", () => { 338 | test_send_request_args('unset', { 339 | args: [['key1', 'key2']], 340 | expected: {$unset: ['key1', 'key2']}, 341 | }); 342 | }); 343 | 344 | it("errors on other argument types", () => { 345 | mixpanel.groups.unset(group_key, group_id, { key1:'val1', key2:'val2' }); 346 | mixpanel.groups.unset(group_key, group_id, 1231241.123); 347 | 348 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 349 | }); 350 | 351 | it("supports being called with a modifiers argument", () => { 352 | test_send_request_args('unset', { 353 | args: ['key1'], 354 | expected: {$unset: ['key1']}, 355 | use_modifiers: true, 356 | }); 357 | }); 358 | 359 | it("supports being called with a callback", () => { 360 | test_send_request_args('unset', { 361 | args: ['key1'], 362 | expected: {$unset: ['key1']}, 363 | use_callback: true, 364 | }); 365 | }); 366 | 367 | it("supports being called with a modifiers argument and a callback", () => { 368 | test_send_request_args('unset', { 369 | args: ['key1'], 370 | expected: {$unset: ['key1']}, 371 | use_callback: true, 372 | use_modifiers: true, 373 | }); 374 | }); 375 | }); 376 | }); 377 | -------------------------------------------------------------------------------- /test/import.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire'), 2 | https = require('https'), 3 | events = require('events'), 4 | Mixpanel = require('../lib/mixpanel-node'); 5 | 6 | var mock_now_time = new Date(2016, 1, 1).getTime(), 7 | six_days_ago_timestamp = mock_now_time - 1000 * 60 * 60 * 24 * 6; 8 | 9 | describe('import', () => { 10 | let mixpanel; 11 | beforeEach(() => { 12 | mixpanel = Mixpanel.init('token', { secret: 'my api secret' }); 13 | 14 | vi.spyOn(mixpanel, 'send_request'); 15 | 16 | return () => { 17 | mixpanel.send_request.mockRestore(); 18 | } 19 | }); 20 | 21 | it('calls send_request with correct endpoint and data', () => { 22 | var event = 'test', 23 | time = six_days_ago_timestamp, 24 | props = { key1: 'val1' }, 25 | expected_endpoint = '/import', 26 | expected_data = { 27 | event: 'test', 28 | properties: expect.objectContaining({ 29 | key1: 'val1', 30 | token: 'token', 31 | time: time, 32 | }), 33 | }; 34 | 35 | mixpanel.import(event, time, props); 36 | 37 | expect(mixpanel.send_request).toHaveBeenCalledWith( 38 | expect.objectContaining({ 39 | endpoint: expected_endpoint, 40 | data: expected_data, 41 | }), 42 | undefined, 43 | ); 44 | }); 45 | 46 | it('supports a Date instance greater than 5 days old', () => { 47 | var event = 'test', 48 | time = new Date(six_days_ago_timestamp), 49 | props = { key1: 'val1' }, 50 | expected_endpoint = '/import', 51 | expected_data = { 52 | event: 'test', 53 | properties: expect.objectContaining({ 54 | key1: 'val1', 55 | token: 'token', 56 | time: six_days_ago_timestamp, 57 | }), 58 | }; 59 | 60 | mixpanel.import(event, time, props); 61 | 62 | expect(mixpanel.send_request).toHaveBeenCalledWith( 63 | expect.objectContaining({ 64 | endpoint: expected_endpoint, 65 | data: expected_data, 66 | }), 67 | undefined, 68 | ); 69 | }); 70 | 71 | it('supports a Date instance less than 5 days old', () => { 72 | var event = 'test', 73 | time = new Date(mock_now_time), 74 | props = { key1: 'val1' }, 75 | expected_endpoint = '/import', 76 | expected_data = { 77 | event: 'test', 78 | properties: expect.objectContaining({ 79 | key1: 'val1', 80 | token: 'token', 81 | time: mock_now_time, 82 | }), 83 | }; 84 | 85 | mixpanel.import(event, time, props); 86 | 87 | expect(mixpanel.send_request).toHaveBeenCalledWith( 88 | expect.objectContaining({ 89 | endpoint: expected_endpoint, 90 | data: expected_data, 91 | }), 92 | undefined, 93 | ); 94 | }); 95 | 96 | it('supports a unix timestamp', () => { 97 | var event = 'test', 98 | time = mock_now_time, 99 | props = { key1: 'val1' }, 100 | expected_endpoint = '/import', 101 | expected_data = { 102 | event: 'test', 103 | properties: expect.objectContaining({ 104 | key1: 'val1', 105 | token: 'token', 106 | time: time, 107 | }), 108 | }; 109 | 110 | mixpanel.import(event, time, props); 111 | expect(mixpanel.send_request).toHaveBeenCalledWith( 112 | expect.objectContaining({ 113 | endpoint: expected_endpoint, 114 | data: expected_data, 115 | }), 116 | undefined, 117 | ); 118 | }); 119 | 120 | it('requires the time argument to be a number or Date', () => { 121 | expect(() => mixpanel.import('test', new Date())).not.toThrowError(); 122 | expect(() => mixpanel.import('test', Date.now())).not.toThrowError(); 123 | expect(() => mixpanel.import('test', 'not a number or Date')).toThrowError( 124 | /`time` property must be a Date or Unix timestamp/, 125 | ); 126 | expect(() => mixpanel.import('test')).toThrowError( 127 | /`time` property must be a Date or Unix timestamp/, 128 | ); 129 | }); 130 | }); 131 | 132 | describe('import_batch', () => { 133 | let mixpanel; 134 | beforeEach(() => { 135 | mixpanel = Mixpanel.init('token', { secret: 'my api secret' }); 136 | 137 | vi.spyOn(mixpanel, 'send_request'); 138 | 139 | return () => { 140 | mixpanel.send_request.mockRestore(); 141 | }; 142 | }); 143 | 144 | it('calls send_request with correct endpoint, data, and method', () => { 145 | var expected_endpoint = '/import', 146 | event_list = [ 147 | {event: 'test', properties: {key1: 'val1', time: 500 }}, 148 | {event: 'test', properties: {key2: 'val2', time: 1000}}, 149 | {event: 'test2', properties: {key2: 'val2', time: 1500}}, 150 | ], 151 | expected_data = [ 152 | {event: 'test', properties: {key1: 'val1', time: 500, token: 'token'}}, 153 | {event: 'test', properties: {key2: 'val2', time: 1000, token: 'token'}}, 154 | {event: 'test2', properties: {key2: 'val2', time: 1500, token: 'token'}}, 155 | ]; 156 | 157 | mixpanel.import_batch(event_list); 158 | 159 | expect(mixpanel.send_request).toHaveBeenCalledWith( 160 | { 161 | method: 'POST', 162 | endpoint: expected_endpoint, 163 | data: expected_data, 164 | }, 165 | expect.any(Function) 166 | ); 167 | }); 168 | 169 | it('requires the time argument for every event', () => { 170 | var event_list = [ 171 | { event: 'test', properties: { key1: 'val1', time: 500 } }, 172 | { event: 'test', properties: { key2: 'val2', time: 1000 } }, 173 | { event: 'test2', properties: { key2: 'val2' } }, 174 | ]; 175 | expect(() => mixpanel.import_batch(event_list)).toThrowError( 176 | '`time` property must be a Date or Unix timestamp and is only required for `import` endpoint', 177 | ); 178 | }); 179 | 180 | it('batches 50 events at a time', () => { 181 | var event_list = []; 182 | for (var ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 183 | event_list.push({ 184 | event: 'test', 185 | properties: { key1: 'val1', time: 500 + ei }, 186 | }); 187 | } 188 | 189 | mixpanel.import_batch(event_list); 190 | expect(mixpanel.send_request).toHaveBeenCalledTimes(3); 191 | }); 192 | }); 193 | 194 | describe('import_batch_integration', () => { 195 | let mixpanel; 196 | let http_emitter; 197 | let event_list; 198 | let res; 199 | beforeEach(() => { 200 | mixpanel = Mixpanel.init('token', { secret: 'my api secret' }); 201 | 202 | vi.spyOn(https, 'request'); 203 | 204 | http_emitter = new events.EventEmitter(); 205 | 206 | // stub sequence of https responses 207 | res = []; 208 | for (let ri = 0; ri < 5; ri++) { 209 | res.push(new events.EventEmitter()); 210 | https.request.mockImplementationOnce((_, cb) => { 211 | cb(res[ri]); 212 | return { 213 | write: () => {}, 214 | end: () => {}, 215 | on: (event) => {}, 216 | }; 217 | }); 218 | } 219 | 220 | event_list = []; 221 | for (var ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 222 | event_list.push({ 223 | event: 'test', 224 | properties: { key1: 'val1', time: 500 + ei }, 225 | }); 226 | } 227 | 228 | return () => { 229 | https.request.mockRestore(); 230 | } 231 | }); 232 | 233 | it('calls provided callback after all requests finish', () => { 234 | mixpanel.import_batch(event_list, function (error_list) { 235 | expect(https.request).toHaveBeenCalledTimes(3); 236 | expect(error_list).toBe(null); 237 | }); 238 | for (var ri = 0; ri < 3; ri++) { 239 | res[ri].emit('data', '1'); 240 | res[ri].emit('end'); 241 | } 242 | }); 243 | 244 | it('passes error list to callback', () => { 245 | mixpanel.import_batch(event_list, function (error_list) { 246 | expect(error_list.length).toBe(3); 247 | }); 248 | for (var ri = 0; ri < 3; ri++) { 249 | res[ri].emit('data', '0'); 250 | res[ri].emit('end'); 251 | } 252 | }); 253 | 254 | it('calls provided callback when options are passed', () => { 255 | mixpanel.import_batch(event_list, { max_batch_size: 100 }, function (error_list) { 256 | expect(https.request).toHaveBeenCalledTimes(3); 257 | expect(error_list).toBe(null); 258 | }); 259 | for (var ri = 0; ri < 3; ri++) { 260 | res[ri].emit('data', '1'); 261 | res[ri].emit('end'); 262 | } 263 | }); 264 | 265 | it('sends more requests when max_batch_size < 50', () => { 266 | mixpanel.import_batch(event_list, { max_batch_size: 30 }, function (error_list) { 267 | expect(https.request).toHaveBeenCalledTimes(5); // 30 + 30 + 30 + 30 + 10 268 | expect(error_list).toBe(null); 269 | }); 270 | for (var ri = 0; ri < 5; ri++) { 271 | res[ri].emit('data', '1'); 272 | res[ri].emit('end'); 273 | } 274 | }); 275 | 276 | it('can set max concurrent requests', () => { 277 | var async_all_stub = vi.fn(); 278 | var PatchedMixpanel = proxyquire('../lib/mixpanel-node', { 279 | './utils': { async_all: async_all_stub }, 280 | }); 281 | async_all_stub.mockImplementationOnce((_, __, cb) => cb(null)); 282 | mixpanel = PatchedMixpanel.init('token', { secret: 'my api secret' }); 283 | 284 | mixpanel.import_batch( 285 | event_list, 286 | { max_batch_size: 30, max_concurrent_requests: 2 }, 287 | function (error_list) { 288 | // should send 5 event batches over 3 request batches: 289 | // request batch 1: 30 events, 30 events 290 | // request batch 2: 30 events, 30 events 291 | // request batch 3: 10 events 292 | expect(async_all_stub).toHaveBeenCalledTimes(3); 293 | expect(error_list).toBe(null); 294 | }, 295 | ); 296 | for (var ri = 0; ri < 5; ri++) { 297 | res[ri].emit('data', '1'); 298 | res[ri].emit('end'); 299 | } 300 | }); 301 | 302 | it('behaves well without a callback', () => { 303 | mixpanel.import_batch(event_list); 304 | expect(https.request).toHaveBeenCalledTimes(3); 305 | mixpanel.import_batch(event_list, { max_batch_size: 100 }); 306 | expect(https.request).toHaveBeenCalledTimes(5); 307 | }); 308 | }); 309 | -------------------------------------------------------------------------------- /test/logger.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require('../lib/mixpanel-node'); 2 | 3 | describe("logger", () => { 4 | describe("console logger", () => { 5 | let mixpanel; 6 | let consoleDebugFn; 7 | beforeAll(() => { 8 | consoleDebugFn = vi.spyOn(console, 'debug').mockImplementation(() => {}); 9 | 10 | mixpanel = Mixpanel.init('test token'); 11 | mixpanel.send_request = () => {}; 12 | return () => { 13 | consoleDebugFn.mockRestore(); 14 | }; 15 | }); 16 | 17 | it("defaults to console logger", () => { 18 | const loggerName = Object.prototype.toString.call(mixpanel.config.logger); 19 | expect(loggerName).toBe('[object console]'); 20 | }); 21 | 22 | it("throws an error on incorrect logger object", () => { 23 | expect(() => mixpanel.set_config({ logger: false })) 24 | .toThrow(new TypeError('"logger" must be a valid Logger object')); 25 | expect(() => mixpanel.set_config({logger: {log: () => {}}})) 26 | .toThrow(new TypeError('Logger object missing "trace" method')); 27 | }); 28 | 29 | it("writes log for track() method", () => { 30 | mixpanel.set_config({debug: true}); 31 | 32 | mixpanel.track('test', {foo: 'bar'}); 33 | 34 | expect(consoleDebugFn).toHaveBeenCalledTimes(1); 35 | 36 | const [message] = consoleDebugFn.mock.calls[0]; 37 | 38 | expect(message).toMatch(/Sending the following event/); 39 | }); 40 | 41 | it("writes log for increment() method", () => { 42 | mixpanel.set_config({debug: true}); 43 | 44 | mixpanel.people.increment('bob', 'page_views', 1); 45 | 46 | expect(consoleDebugFn).toHaveBeenCalledTimes(2); 47 | 48 | const [message] = consoleDebugFn.mock.calls[1]; 49 | 50 | expect(message).toMatch(/Sending the following data/); 51 | }); 52 | 53 | it("writes log for remove() method", () => { 54 | mixpanel.set_config({debug: true}); 55 | 56 | mixpanel.people.remove('bob', {'browsers': 'firefox'}); 57 | 58 | expect(consoleDebugFn).toHaveBeenCalledTimes(3); 59 | 60 | const [message] = consoleDebugFn.mock.calls[2]; 61 | 62 | expect(message).toMatch(/Sending the following data/); 63 | }); 64 | }); 65 | 66 | describe('custom logger', () => { 67 | let mixpanel; 68 | let customLogger; 69 | let consoleDebugFn; 70 | beforeAll((cb) => { 71 | /** 72 | * Custom logger must be an object with the following methods: 73 | * 74 | * interface CustomLogger { 75 | * trace(message?: any, ...optionalParams: any[]): void; 76 | * debug(message?: any, ...optionalParams: any[]): void; 77 | * info(message?: any, ...optionalParams: any[]): void; 78 | * warn(message?: any, ...optionalParams: any[]): void; 79 | * error(message?: any, ...optionalParams: any[]): void; 80 | * } 81 | */ 82 | customLogger = { 83 | trace: vi.fn(), 84 | debug: vi.fn(), 85 | info: vi.fn(), 86 | warn: vi.fn(), 87 | error: vi.fn(), 88 | }; 89 | consoleDebugFn = vi.spyOn(console, 'debug'); 90 | 91 | mixpanel = Mixpanel.init('test token', {logger: customLogger}); 92 | 93 | mixpanel.send_request = () => {}; 94 | 95 | return () => { 96 | consoleDebugFn.mockRestore(); 97 | } 98 | }); 99 | 100 | it("writes log for track() method", () => { 101 | mixpanel.set_config({debug: true}); 102 | 103 | mixpanel.track('test', {foo: 'bar'}); 104 | 105 | expect(customLogger.debug).toHaveBeenCalledTimes(1); 106 | expect(consoleDebugFn).toHaveBeenCalledTimes(0); 107 | 108 | const [message] = customLogger.debug.mock.calls[0]; 109 | 110 | expect(message).toMatch(/Sending the following event/) 111 | }); 112 | 113 | it("writes log for increment() method", () => { 114 | mixpanel.set_config({debug: true}); 115 | 116 | mixpanel.people.increment('bob', 'page_views', 1); 117 | 118 | expect(customLogger.debug).toHaveBeenCalledTimes(2); 119 | expect(consoleDebugFn).toHaveBeenCalledTimes(0); 120 | 121 | const [message] = customLogger.debug.mock.calls[1]; 122 | 123 | expect(message).toMatch(/Sending the following data/); 124 | }); 125 | 126 | it("writes log for remove() method", (test) => { 127 | mixpanel.set_config({debug: true}); 128 | 129 | mixpanel.people.remove('bob', {'browsers': 'firefox'}); 130 | expect(customLogger.debug).toHaveBeenCalledTimes(3); 131 | expect(consoleDebugFn).toHaveBeenCalledTimes(0); 132 | 133 | const [message] = customLogger.debug.mock.calls[2]; 134 | 135 | expect(message).toMatch(/Sending the following data/) 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/people.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require('../lib/mixpanel-node'); 2 | const {create_profile_helpers} = require('../lib/profile_helpers'); 3 | 4 | describe('people', () => { 5 | const endpoint = '/engage'; 6 | const distinct_id = 'user1'; 7 | const token = 'token'; 8 | let mixpanel; 9 | beforeEach(() => { 10 | mixpanel = Mixpanel.init(token); 11 | vi.spyOn(mixpanel, 'send_request') 12 | 13 | return () => { 14 | mixpanel.send_request.mockRestore(); 15 | } 16 | }); 17 | 18 | // shared test case 19 | const test_send_request_args = function(func, {args, expected, use_modifiers, use_callback} = {}) { 20 | let expected_data = {$token: token, $distinct_id: distinct_id, ...expected}; 21 | let callback; 22 | 23 | args = [distinct_id, ...(args ? args : [])]; 24 | 25 | if (use_modifiers) { 26 | var modifiers = { 27 | '$ignore_alias': true, 28 | '$ignore_time': true, 29 | '$ip': '1.2.3.4', 30 | '$time': 1234567890, 31 | '$latitude': 40.7127753, 32 | '$longitude': -74.0059728, 33 | }; 34 | Object.assign(expected_data, modifiers); 35 | args.push(modifiers); 36 | } 37 | if (use_callback) { 38 | callback = function() {}; 39 | args.push(callback); 40 | } 41 | 42 | mixpanel.people[func](...args); 43 | 44 | const expectedSendRequestArgs = [ 45 | { method: 'GET', endpoint, data: expected_data }, 46 | use_callback ? callback : undefined, 47 | ]; 48 | expect(mixpanel.send_request).toHaveBeenCalledWith(...expectedSendRequestArgs) 49 | }; 50 | 51 | describe("_set", () => { 52 | it("handles set_once correctly", () => { 53 | test_send_request_args('set_once', { 54 | args: ['key1', 'val1'], 55 | expected: {$set_once: {'key1': 'val1'}}, 56 | }); 57 | }); 58 | 59 | it("calls send_request with correct endpoint and data", () => { 60 | test_send_request_args('set', { 61 | args: ['key1', 'val1'], 62 | expected: {$set: {'key1': 'val1'}}, 63 | }); 64 | }); 65 | 66 | it("supports being called with a property object", () => { 67 | test_send_request_args('set', { 68 | args: [{'key1': 'val1', 'key2': 'val2'}], 69 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 70 | }); 71 | }); 72 | 73 | it("supports being called with a property object (set_once)", () => { 74 | test_send_request_args('set_once', { 75 | args: [{'key1': 'val1', 'key2': 'val2'}], 76 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 77 | }); 78 | }); 79 | 80 | it("supports being called with a modifiers argument", () => { 81 | test_send_request_args('set', { 82 | args: ['key1', 'val1'], 83 | expected: {$set: {'key1': 'val1'}}, 84 | use_modifiers: true, 85 | }); 86 | }); 87 | 88 | it("supports being called with a modifiers argument (set_once)", () => { 89 | test_send_request_args('set_once', { 90 | args: ['key1', 'val1'], 91 | expected: {$set_once: {'key1': 'val1'}}, 92 | use_modifiers: true, 93 | }); 94 | }); 95 | 96 | it("supports being called with a properties object and a modifiers argument", () => { 97 | test_send_request_args('set', { 98 | args: [{'key1': 'val1', 'key2': 'val2'}], 99 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 100 | use_modifiers: true, 101 | }); 102 | }); 103 | 104 | it("supports being called with a properties object and a modifiers argument (set_once)", () => { 105 | test_send_request_args('set_once', { 106 | args: [{'key1': 'val1', 'key2': 'val2'}], 107 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 108 | use_modifiers: true, 109 | }); 110 | }); 111 | 112 | it("handles the ip property in a property object properly", () => { 113 | test_send_request_args('set', { 114 | args: [{'ip': '1.2.3.4', 'key1': 'val1', 'key2': 'val2'}], 115 | expected: { 116 | $ip: '1.2.3.4', 117 | $set: {'key1': 'val1', 'key2': 'val2'}, 118 | }, 119 | }); 120 | }); 121 | 122 | it("handles the $ignore_time property in a property object properly", () => { 123 | test_send_request_args('set', { 124 | args: [{'$ignore_time': true, 'key1': 'val1', 'key2': 'val2'}], 125 | expected: { 126 | $ignore_time: true, 127 | $set: {'key1': 'val1', 'key2': 'val2'}, 128 | }, 129 | }); 130 | }); 131 | 132 | it("supports being called with a callback", () => { 133 | test_send_request_args('set', { 134 | args: ['key1', 'val1'], 135 | expected: {$set: {'key1': 'val1'}}, 136 | use_callback: true, 137 | }); 138 | }); 139 | 140 | it("supports being called with a callback (set_once)", () => { 141 | test_send_request_args('set_once', { 142 | args: ['key1', 'val1'], 143 | expected: {$set_once: {'key1': 'val1'}}, 144 | use_callback: true, 145 | }); 146 | }); 147 | 148 | it("supports being called with a properties object and a callback", () => { 149 | test_send_request_args('set', { 150 | args: [{'key1': 'val1', 'key2': 'val2'}], 151 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 152 | use_callback: true, 153 | }); 154 | }); 155 | 156 | it("supports being called with a properties object and a callback (set_once)", () => { 157 | test_send_request_args('set_once', { 158 | args: [{'key1': 'val1', 'key2': 'val2'}], 159 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 160 | use_callback: true, 161 | }); 162 | }); 163 | 164 | it("supports being called with a modifiers argument and a callback", () => { 165 | test_send_request_args('set', { 166 | args: ['key1', 'val1'], 167 | expected: {$set: {'key1': 'val1'}}, 168 | use_callback: true, 169 | use_modifiers: true, 170 | }); 171 | }); 172 | 173 | it("supports being called with a modifiers argument and a callback (set_once)", () => { 174 | test_send_request_args('set_once', { 175 | args: ['key1', 'val1'], 176 | expected: {$set_once: {'key1': 'val1'}}, 177 | use_callback: true, 178 | use_modifiers: true, 179 | }); 180 | }); 181 | 182 | it("supports being called with a properties object, a modifiers argument and a callback", () => { 183 | test_send_request_args('set', { 184 | args: [{'key1': 'val1', 'key2': 'val2'}], 185 | expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, 186 | use_callback: true, 187 | use_modifiers: true, 188 | }); 189 | }); 190 | 191 | it("supports being called with a properties object, a modifiers argument and a callback (set_once)", () => { 192 | test_send_request_args('set_once', { 193 | args: [{'key1': 'val1', 'key2': 'val2'}], 194 | expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, 195 | use_callback: true, 196 | use_modifiers: true, 197 | }); 198 | }); 199 | }); 200 | 201 | describe("increment", () => { 202 | it("calls send_request with correct endpoint and data", () => { 203 | test_send_request_args('increment', { 204 | args: ['key1'], 205 | expected: {$add: {'key1': 1}}, 206 | }); 207 | }); 208 | 209 | it("supports incrementing key by value", () => { 210 | test_send_request_args('increment', { 211 | args: ['key1', 2], 212 | expected: {$add: {'key1': 2}}, 213 | }); 214 | }); 215 | 216 | it("supports incrementing key by value and a modifiers argument", () => { 217 | test_send_request_args('increment', { 218 | args: ['key1', 2], 219 | expected: {$add: {'key1': 2}}, 220 | use_modifiers: true, 221 | }); 222 | }); 223 | 224 | it("supports incrementing multiple keys", () => { 225 | test_send_request_args('increment', { 226 | args: [{'key1': 5, 'key2': -3}], 227 | expected: {$add: {'key1': 5, 'key2': -3}}, 228 | }); 229 | }); 230 | 231 | it("supports incrementing multiple keys and a modifiers argument", () => { 232 | test_send_request_args('increment', { 233 | args: [{'key1': 5, 'key2': -3}], 234 | expected: {$add: {'key1': 5, 'key2': -3}}, 235 | use_modifiers: true, 236 | }); 237 | }); 238 | 239 | it("ignores invalid values", () => { 240 | test_send_request_args('increment', { 241 | args: [{ 242 | 'key1': 'bad', 243 | 'key2': 3, 244 | 'key3': undefined, 245 | 'key4': '5', 246 | 'key5': new Date(), 247 | 'key6': function() {}, 248 | }], 249 | expected: {$add: {'key2': 3, 'key4': '5'}}, 250 | }); 251 | }); 252 | 253 | it("supports being called with a callback", () => { 254 | test_send_request_args('increment', { 255 | args: ['key1'], 256 | expected: {$add: {'key1': 1}}, 257 | use_callback: true, 258 | }); 259 | }); 260 | 261 | it("supports incrementing key by value with a callback", () => { 262 | test_send_request_args('increment', { 263 | args: ['key1', 2], 264 | expected: {$add: {'key1': 2}}, 265 | use_callback: true, 266 | }); 267 | }); 268 | 269 | it("supports incrementing key by value with a modifiers argument and callback", () => { 270 | test_send_request_args('increment', { 271 | args: ['key1', 2], 272 | expected: {$add: {'key1': 2}}, 273 | use_callback: true, 274 | use_modifiers: true, 275 | }); 276 | }); 277 | 278 | it("supports incrementing multiple keys with a callback", () => { 279 | test_send_request_args('increment', { 280 | args: [{'key1': 5, 'key2': -3}], 281 | expected: {$add: {'key1': 5, 'key2': -3}}, 282 | use_callback: true, 283 | }); 284 | }); 285 | 286 | it("supports incrementing multiple keys with a modifiers argument and callback", () => { 287 | test_send_request_args('increment', { 288 | args: [{'key1': 5, 'key2': -3}], 289 | expected: {$add: {'key1': 5, 'key2': -3}}, 290 | use_callback: true, 291 | use_modifiers: true, 292 | }); 293 | }); 294 | }); 295 | 296 | describe("append", () => { 297 | it("calls send_request with correct endpoint and data", () => { 298 | test_send_request_args('append', { 299 | args: ['key1', 'value'], 300 | expected: {$append: {'key1': 'value'}}, 301 | }); 302 | }); 303 | 304 | it("supports being called with modifiers", () => { 305 | test_send_request_args('append', { 306 | args: ['key1', 'value'], 307 | expected: {$append: {'key1': 'value'}}, 308 | use_modifiers: true, 309 | }); 310 | }); 311 | 312 | it("supports being called with a callback", () => { 313 | test_send_request_args('append', { 314 | args: ['key1', 'value'], 315 | expected: {$append: {'key1': 'value'}}, 316 | use_callback: true, 317 | }); 318 | }); 319 | 320 | it("supports appending multiple keys with values", () => { 321 | test_send_request_args('append', { 322 | args: [{'key1': 'value1', 'key2': 'value2'}], 323 | expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, 324 | }); 325 | }); 326 | 327 | it("supports appending multiple keys with values and a modifiers argument", () => { 328 | test_send_request_args('append', { 329 | args: [{'key1': 'value1', 'key2': 'value2'}], 330 | expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, 331 | use_modifiers: true, 332 | }); 333 | }); 334 | 335 | it("supports appending multiple keys with values and a callback", () => { 336 | test_send_request_args('append', { 337 | args: [{'key1': 'value1', 'key2': 'value2'}], 338 | expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, 339 | use_callback: true, 340 | }); 341 | }); 342 | 343 | it("supports appending multiple keys with values with a modifiers argument and callback", () => { 344 | test_send_request_args('append', { 345 | args: [{'key1': 'value1', 'key2': 'value2'}], 346 | expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, 347 | use_callback: true, 348 | use_modifiers: true, 349 | }); 350 | }); 351 | }); 352 | 353 | describe("track_charge", () => { 354 | it("calls send_request with correct endpoint and data", () => { 355 | test_send_request_args('track_charge', { 356 | args: [50], 357 | expected: {$append: {$transactions: {$amount: 50}}}, 358 | }); 359 | }); 360 | 361 | it("supports being called with a property object", () => { 362 | var time = new Date('Feb 1 2012'); 363 | test_send_request_args('track_charge', { 364 | args: [50, {$time: time, isk: 'isk'}], 365 | expected: {$append: {$transactions: { 366 | $amount: 50, 367 | $time: time.toISOString(), 368 | isk: 'isk', 369 | }}}, 370 | }); 371 | }); 372 | 373 | it("supports being called with a modifiers argument", () => { 374 | test_send_request_args('track_charge', { 375 | args: [50], 376 | expected: {$append: {$transactions: {$amount: 50}}}, 377 | use_modifiers: true, 378 | }); 379 | }); 380 | 381 | it("supports being called with a property object and a modifiers argument", () => { 382 | var time = new Date('Feb 1 2012'); 383 | test_send_request_args('track_charge', { 384 | args: [50, {$time: time, isk: 'isk'}], 385 | expected: {$append: {$transactions: { 386 | $amount: 50, 387 | $time: time.toISOString(), 388 | isk: 'isk', 389 | }}}, 390 | use_modifiers: true, 391 | }); 392 | }); 393 | 394 | it("supports being called with a callback", () => { 395 | test_send_request_args('track_charge', { 396 | args: [50], 397 | expected: {$append: {$transactions: {$amount: 50}}}, 398 | use_callback: true, 399 | }); 400 | }); 401 | 402 | it("supports being called with properties and a callback", () => { 403 | test_send_request_args('track_charge', { 404 | args: [50, {}], 405 | expected: {$append: {$transactions: {$amount: 50}}}, 406 | use_callback: true, 407 | }); 408 | }); 409 | 410 | it("supports being called with modifiers and a callback", () => { 411 | test_send_request_args('track_charge', { 412 | args: [50], 413 | expected: {$append: {$transactions: {$amount: 50}}}, 414 | use_callback: true, 415 | use_modifiers: true, 416 | }); 417 | }); 418 | 419 | it("supports being called with properties, modifiers and a callback", () => { 420 | var time = new Date('Feb 1 2012'); 421 | test_send_request_args('track_charge', { 422 | args: [50, {$time: time, isk: 'isk'}], 423 | expected: {$append: {$transactions: { 424 | $amount: 50, 425 | $time: time.toISOString(), 426 | isk: 'isk', 427 | }}}, 428 | use_callback: true, 429 | use_modifiers: true, 430 | }); 431 | }); 432 | }); 433 | 434 | describe("clear_charges", () => { 435 | it("calls send_request with correct endpoint and data", () => { 436 | test_send_request_args('clear_charges', { 437 | expected: {$set: {$transactions: []}}, 438 | }); 439 | }); 440 | 441 | it("supports being called with a modifiers argument", () => { 442 | test_send_request_args('clear_charges', { 443 | expected: {$set: {$transactions: []}}, 444 | use_modifiers: true, 445 | }); 446 | }); 447 | 448 | it("supports being called with a callback", () => { 449 | test_send_request_args('clear_charges', { 450 | expected: {$set: {$transactions: []}}, 451 | use_callback: true, 452 | }); 453 | }); 454 | 455 | it("supports being called with a modifiers argument and a callback", () => { 456 | test_send_request_args('clear_charges', { 457 | expected: {$set: {$transactions: []}}, 458 | use_callback: true, 459 | use_modifiers: true, 460 | }); 461 | }); 462 | }); 463 | 464 | describe("delete_user", () => { 465 | it("calls send_request with correct endpoint and data", () => { 466 | test_send_request_args('delete_user', { 467 | expected: {$delete: ''}, 468 | }); 469 | }); 470 | 471 | it("supports being called with a modifiers argument", () => { 472 | test_send_request_args('delete_user', { 473 | expected: {$delete: ''}, 474 | use_modifiers: true, 475 | }); 476 | }); 477 | 478 | it("supports being called with a callback", () => { 479 | test_send_request_args('delete_user', { 480 | expected: {$delete: ''}, 481 | use_callback: true, 482 | }); 483 | }); 484 | 485 | it("supports being called with a modifiers argument and a callback", () => { 486 | test_send_request_args('delete_user', { 487 | expected: {$delete: ''}, 488 | use_callback: true, 489 | use_modifiers: true, 490 | }); 491 | }); 492 | }); 493 | 494 | describe("remove", () => { 495 | it("calls send_request with correct endpoint and data", () => { 496 | test_send_request_args('remove', { 497 | args: [{'key1': 'value1', 'key2': 'value2'}], 498 | expected: {$remove: {'key1': 'value1', 'key2': 'value2'}}, 499 | }); 500 | }); 501 | 502 | it("errors on non-scalar argument types", () => { 503 | mixpanel.people.remove(distinct_id, {'key1': ['value1']}); 504 | mixpanel.people.remove(distinct_id, {key1: {key: 'val'}}); 505 | mixpanel.people.remove(distinct_id, 1231241.123); 506 | mixpanel.people.remove(distinct_id, [5]); 507 | mixpanel.people.remove(distinct_id, {key1: function() {}}); 508 | mixpanel.people.remove(distinct_id, {key1: [function() {}]}); 509 | 510 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 511 | }); 512 | 513 | it("supports being called with a modifiers argument", () => { 514 | test_send_request_args('remove', { 515 | args: [{'key1': 'value1'}], 516 | expected: {$remove: {'key1': 'value1'}}, 517 | use_modifiers: true, 518 | }); 519 | }); 520 | 521 | it("supports being called with a callback", () => { 522 | test_send_request_args('remove', { 523 | args: [{'key1': 'value1'}], 524 | expected: {$remove: {'key1': 'value1'}}, 525 | use_callback: true, 526 | }); 527 | }); 528 | 529 | it("supports being called with a modifiers argument and a callback", () => { 530 | test_send_request_args('remove', { 531 | args: [{'key1': 'value1'}], 532 | expected: {$remove: {'key1': 'value1'}}, 533 | use_callback: true, 534 | use_modifiers: true, 535 | }); 536 | }); 537 | }); 538 | 539 | describe("union", () => { 540 | it("calls send_request with correct endpoint and data", () => { 541 | test_send_request_args('union', { 542 | args: [{'key1': ['value1', 'value2']}], 543 | expected: {$union: {'key1': ['value1', 'value2']}}, 544 | }); 545 | }); 546 | 547 | it("supports being called with a scalar value", () => { 548 | test_send_request_args('union', { 549 | args: [{'key1': 'value1'}], 550 | expected: {$union: {'key1': ['value1']}}, 551 | }); 552 | }); 553 | 554 | it("errors on other argument types", () => { 555 | mixpanel.people.union(distinct_id, {key1: {key: 'val'}}); 556 | mixpanel.people.union(distinct_id, 1231241.123); 557 | mixpanel.people.union(distinct_id, [5]); 558 | mixpanel.people.union(distinct_id, {key1: function() {}}); 559 | mixpanel.people.union(distinct_id, {key1: [function() {}]}); 560 | 561 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 562 | }); 563 | 564 | it("supports being called with a modifiers argument", () => { 565 | test_send_request_args('union', { 566 | args: [{'key1': ['value1', 'value2']}], 567 | expected: {$union: {'key1': ['value1', 'value2']}}, 568 | use_modifiers: true, 569 | }); 570 | }); 571 | 572 | it("supports being called with a callback", () => { 573 | test_send_request_args('union', { 574 | args: [{'key1': ['value1', 'value2']}], 575 | expected: {$union: {'key1': ['value1', 'value2']}}, 576 | use_callback: true, 577 | }); 578 | }); 579 | 580 | it("supports being called with a modifiers argument and a callback", () => { 581 | test_send_request_args('union', { 582 | args: [{'key1': ['value1', 'value2']}], 583 | expected: {$union: {'key1': ['value1', 'value2']}}, 584 | use_callback: true, 585 | use_modifiers: true, 586 | }); 587 | }); 588 | }); 589 | 590 | describe("unset", () => { 591 | it("calls send_request with correct endpoint and data", () => { 592 | test_send_request_args('unset', { 593 | args: ['key1'], 594 | expected: {$unset: ['key1']}, 595 | }); 596 | }); 597 | 598 | it("supports being called with a property array", () => { 599 | test_send_request_args('unset', { 600 | args: [['key1', 'key2']], 601 | expected: {$unset: ['key1', 'key2']}, 602 | }); 603 | }); 604 | 605 | it("errors on other argument types", () => { 606 | mixpanel.people.unset(distinct_id, { key1:'val1', key2:'val2' }); 607 | mixpanel.people.unset(distinct_id, 1231241.123); 608 | 609 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 610 | }); 611 | 612 | it("supports being called with a modifiers argument", () => { 613 | test_send_request_args('unset', { 614 | args: ['key1'], 615 | expected: {$unset: ['key1']}, 616 | use_modifiers: true, 617 | }); 618 | }); 619 | 620 | it("supports being called with a callback", () => { 621 | test_send_request_args('unset', { 622 | args: ['key1'], 623 | expected: {$unset: ['key1']}, 624 | use_callback: true, 625 | }); 626 | }); 627 | 628 | it("supports being called with a modifiers argument and a callback", () => { 629 | test_send_request_args('unset', { 630 | args: ['key1'], 631 | expected: {$unset: ['key1']}, 632 | use_callback: true, 633 | use_modifiers: true, 634 | }); 635 | }); 636 | }); 637 | }); 638 | -------------------------------------------------------------------------------- /test/send_request.js: -------------------------------------------------------------------------------- 1 | let Mixpanel; 2 | const proxyquire = require('proxyquire'); 3 | const https = require('https'); 4 | const events = require('events'); 5 | const httpProxyOrig = process.env.HTTP_PROXY; 6 | const httpsProxyOrig = process.env.HTTPS_PROXY; 7 | let HttpsProxyAgent; 8 | 9 | describe("send_request", () => { 10 | let mixpanel; 11 | let http_emitter; 12 | let res; 13 | beforeEach(() => { 14 | HttpsProxyAgent = vi.fn(); 15 | Mixpanel = proxyquire('../lib/mixpanel-node', { 16 | 'https-proxy-agent': HttpsProxyAgent, 17 | }); 18 | 19 | http_emitter = new events.EventEmitter(); 20 | res = new events.EventEmitter(); 21 | vi.spyOn(https, 'request') 22 | .mockImplementation((_, cb) => { 23 | cb(res); 24 | return http_emitter; 25 | }) 26 | http_emitter.write = vi.fn(); 27 | http_emitter.end = vi.fn(); 28 | 29 | mixpanel = Mixpanel.init('token'); 30 | 31 | return () => { 32 | https.request.mockRestore(); 33 | 34 | // restore proxy variables 35 | process.env.HTTP_PROXY = httpProxyOrig; 36 | process.env.HTTPS_PROXY = httpsProxyOrig; 37 | } 38 | }); 39 | 40 | it("sends correct data on GET", () => { 41 | var endpoint = "/track", 42 | data = { 43 | event: 'test', 44 | properties: { 45 | key1: 'val1', 46 | token: 'token', 47 | time: 1346876621 48 | } 49 | }, 50 | expected_http_request = { 51 | method: 'GET', 52 | host: 'api.mixpanel.com', 53 | headers: {}, 54 | path: '/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D' 55 | }; 56 | 57 | mixpanel.send_request({ method: 'get', endpoint: endpoint, data: data }); 58 | expect(https.request).toHaveBeenCalledWith( 59 | expect.objectContaining(expected_http_request), 60 | expect.any(Function) 61 | ); 62 | expect(http_emitter.end).toHaveBeenCalledTimes(1); 63 | expect(http_emitter.write).toHaveBeenCalledTimes(0); 64 | }); 65 | 66 | it("defaults to GET", () => { 67 | var endpoint = "/track", 68 | data = { 69 | event: 'test', 70 | properties: { 71 | key1: 'val1', 72 | token: 'token', 73 | time: 1346876621 74 | } 75 | }, 76 | expected_http_request = { 77 | method: 'GET', 78 | host: 'api.mixpanel.com', 79 | headers: {}, 80 | path: '/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D' 81 | }; 82 | 83 | mixpanel.send_request({ endpoint: endpoint, data: data }); // method option not defined 84 | 85 | expect(https.request).toHaveBeenCalledWith( 86 | expect.objectContaining(expected_http_request), 87 | expect.any(Function), 88 | ); 89 | }); 90 | 91 | it("sends correct data on POST", () => { 92 | var endpoint = "/track", 93 | data = { 94 | event: 'test', 95 | properties: { 96 | key1: 'val1', 97 | token: 'token', 98 | time: 1346876621 99 | } 100 | }, 101 | expected_http_request = { 102 | method: 'POST', 103 | host: 'api.mixpanel.com', 104 | headers: expect.any(Object), 105 | path: '/track?ip=0&verbose=0' 106 | }, 107 | expected_http_request_body = "data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ=="; 108 | 109 | mixpanel.send_request({ method: 'post', endpoint: endpoint, data: data }); 110 | 111 | expect(https.request).toHaveBeenCalledWith( 112 | expect.objectContaining(expected_http_request), 113 | expect.any(Function), 114 | ); 115 | expect(http_emitter.end).toHaveBeenCalledTimes(1); 116 | expect(http_emitter.write).toHaveBeenCalledWith(expected_http_request_body); 117 | }); 118 | 119 | it("sets ip=1 when geolocate option is on", () => { 120 | mixpanel.set_config({ geolocate: true }); 121 | 122 | mixpanel.send_request({ method: "get", endpoint: "/track", event: "test", data: {} }); 123 | 124 | expect(https.request).toHaveBeenCalledWith( 125 | expect.objectContaining({ 126 | path: expect.stringContaining('ip=1'), 127 | }), 128 | expect.any(Function), 129 | ); 130 | }); 131 | 132 | it("handles mixpanel errors", () => { 133 | mixpanel.send_request({ endpoint: "/track", data: { event: "test" } }, function(e) { 134 | expect(e.message).toBe('Mixpanel Server Error: 0') 135 | }); 136 | 137 | res.emit('data', '0'); 138 | res.emit('end'); 139 | }); 140 | 141 | it("handles https.request errors", () => { 142 | mixpanel.send_request({ endpoint: "/track", data: { event: "test" } }, function(e) { 143 | expect(e).toBe('error'); 144 | }); 145 | http_emitter.emit('error', 'error'); 146 | }); 147 | 148 | it("default use keepAlive agent", () => { 149 | var agent = new https.Agent({ keepAlive: false }); 150 | var httpsStub = { 151 | request: vi.fn().mockImplementation((_, cb) => { 152 | cb(res) 153 | return http_emitter; 154 | }), 155 | Agent: vi.fn().mockReturnValue(agent), 156 | }; 157 | // force SDK not use `undefined` string to initialize proxy-agent 158 | delete process.env.HTTP_PROXY 159 | delete process.env.HTTPS_PROXY 160 | Mixpanel = proxyquire('../lib/mixpanel-node', { 161 | 'https': httpsStub 162 | }); 163 | var proxyMixpanel = Mixpanel.init('token'); 164 | proxyMixpanel.send_request({ endpoint: '', data: {} }); 165 | 166 | var getConfig = httpsStub.request.mock.calls[0][0]; 167 | var agentOpts = httpsStub.Agent.mock.calls[0][0]; 168 | expect(agentOpts.keepAlive).toBe(true); 169 | expect(getConfig.agent).toBe(agent); 170 | }); 171 | 172 | it("uses correct hostname", () => { 173 | var host = 'testhost.fakedomain'; 174 | var customHostnameMixpanel = Mixpanel.init('token', { host: host }); 175 | var expected_http_request = { 176 | host: host 177 | }; 178 | 179 | customHostnameMixpanel.send_request({ endpoint: "", data: {} }); 180 | 181 | expect(https.request).toHaveBeenCalledWith( 182 | expect.objectContaining(expected_http_request), 183 | expect.any(Function), 184 | ); 185 | }); 186 | 187 | it("uses correct port", () => { 188 | var host = 'testhost.fakedomain:1337'; 189 | var customHostnameMixpanel = Mixpanel.init('token', { host: host }); 190 | var expected_http_request = { 191 | host: 'testhost.fakedomain', 192 | port: 1337 193 | }; 194 | 195 | customHostnameMixpanel.send_request({ endpoint: "", data: {} }); 196 | 197 | expect(https.request).toHaveBeenCalledWith( 198 | expect.objectContaining(expected_http_request), 199 | expect.any(Function), 200 | ); 201 | }); 202 | 203 | it("uses correct path", () => { 204 | var host = 'testhost.fakedomain'; 205 | var customPath = '/mypath'; 206 | var customHostnameMixpanel = Mixpanel.init('token', { 207 | host, 208 | path: customPath, 209 | }); 210 | var expected_http_request = { 211 | host, 212 | path: '/mypath?ip=0&verbose=0&data=e30%3D', 213 | }; 214 | 215 | customHostnameMixpanel.send_request({endpoint: "", data: {}}); 216 | expect(https.request).toHaveBeenCalledWith( 217 | expect.objectContaining(expected_http_request), 218 | expect.any(Function), 219 | ); 220 | }); 221 | 222 | it("combines custom path and endpoint", () => { 223 | var host = 'testhost.fakedomain'; 224 | var customPath = '/mypath'; 225 | var customHostnameMixpanel = Mixpanel.init('token', { 226 | host, 227 | path: customPath, 228 | }); 229 | var expected_http_request = { 230 | host, 231 | path: '/mypath/track?ip=0&verbose=0&data=e30%3D', 232 | }; 233 | 234 | customHostnameMixpanel.send_request({endpoint: '/track', data: {}}); 235 | expect(https.request).toHaveBeenCalledWith( 236 | expect.objectContaining(expected_http_request), 237 | expect.any(Function), 238 | ); 239 | }); 240 | 241 | it("uses HTTP_PROXY if set", () => { 242 | HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount 243 | delete process.env.HTTPS_PROXY; 244 | process.env.HTTP_PROXY = 'this.aint.real.https'; 245 | 246 | var proxyMixpanel = Mixpanel.init('token'); 247 | proxyMixpanel.send_request({ endpoint: '', data: {} }); 248 | 249 | expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); 250 | 251 | var agentOpts = HttpsProxyAgent.mock.calls[0][0]; 252 | expect(agentOpts.pathname).toBe('this.aint.real.https'); 253 | expect(agentOpts.keepAlive).toBe(true); 254 | 255 | var getConfig = https.request.mock.calls[0][0]; 256 | expect(getConfig.agent).toBeTruthy(); 257 | }); 258 | 259 | it("uses HTTPS_PROXY if set", () => { 260 | HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount 261 | delete process.env.HTTP_PROXY; 262 | process.env.HTTPS_PROXY = 'this.aint.real.https'; 263 | 264 | var proxyMixpanel = Mixpanel.init('token'); 265 | proxyMixpanel.send_request({ endpoint: '', data: {} }); 266 | 267 | expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); 268 | 269 | var proxyOpts = HttpsProxyAgent.mock.calls[0][0]; 270 | expect(proxyOpts.pathname).toBe('this.aint.real.https'); 271 | 272 | var getConfig = https.request.mock.calls[0][0]; 273 | expect(getConfig.agent).toBeTruthy(); 274 | }); 275 | 276 | it("requires credentials for import requests", () => { 277 | expect(() => { 278 | mixpanel.send_request({ 279 | endpoint: `/import`, 280 | data: {event: `test event`}, 281 | }) 282 | }).toThrowError( 283 | /The Mixpanel Client needs a Mixpanel API Secret when importing old events/, 284 | ) 285 | }); 286 | 287 | it("sets basic auth header if API secret is provided", () => { 288 | mixpanel.set_config({secret: `foobar`}); 289 | mixpanel.send_request({ 290 | endpoint: `/import`, 291 | data: {event: `test event`}, 292 | }); 293 | expect(https.request).toHaveBeenCalledTimes(1); 294 | expect(https.request.mock.calls[0][0].headers).toEqual({ 295 | 'Authorization': `Basic Zm9vYmFyOg==`, // base64 of "foobar:" 296 | }) 297 | }); 298 | 299 | it("still supports import with api_key (legacy)", () => { 300 | mixpanel.set_config({key: `barbaz`}); 301 | mixpanel.send_request({ 302 | endpoint: `/import`, 303 | data: {}, 304 | }); 305 | expect(https.request).toHaveBeenCalledTimes(1); 306 | expect(https.request.mock.calls[0][0].path).toBe( 307 | `/import?ip=0&verbose=0&data=e30%3D&api_key=barbaz`, 308 | ); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /test/track.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const events = require('events'); 3 | const proxyquire = require('proxyquire'); 4 | const Mixpanel = require('../lib/mixpanel-node'); 5 | const packageInfo = require('../package.json'); 6 | const utils = require('../lib/utils'); 7 | 8 | var mock_now_time = new Date(2016, 1, 1).getTime(); 9 | 10 | describe('track', () => { 11 | let mixpanel; 12 | beforeAll(() => { 13 | mixpanel = Mixpanel.init('token'); 14 | vi.useFakeTimers(); 15 | vi.setSystemTime(mock_now_time); 16 | vi.spyOn(mixpanel, 'send_request'); 17 | 18 | return () => { 19 | vi.useRealTimers(); 20 | mixpanel.send_request.mockRestore(); 21 | } 22 | }); 23 | 24 | it('calls send_request with correct endpoint and data', () => { 25 | var event = 'test', 26 | props = { key1: 'val1' }, 27 | expected_endpoint = '/track', 28 | expected_data = { 29 | event: 'test', 30 | properties: expect.objectContaining({ 31 | key1: 'val1', 32 | token: 'token' 33 | }), 34 | }; 35 | 36 | mixpanel.track(event, props); 37 | 38 | expect(mixpanel.send_request).toHaveBeenCalledWith( 39 | expect.objectContaining({ 40 | endpoint: expected_endpoint, 41 | data: expected_data 42 | }), 43 | undefined, 44 | ); 45 | }); 46 | 47 | it('can be called with optional properties', () => { 48 | var expected_endpoint = '/track', 49 | expected_data = { 50 | event: 'test', 51 | properties: expect.objectContaining({ 52 | token: 'token', 53 | }), 54 | }; 55 | 56 | mixpanel.track('test'); 57 | 58 | expect(mixpanel.send_request).toHaveBeenCalledWith( 59 | expect.objectContaining({ 60 | endpoint: expected_endpoint, 61 | data: expected_data, 62 | }), 63 | undefined, 64 | ); 65 | }); 66 | 67 | it('can be called with optional callback', (test) => { 68 | var expected_endpoint = '/track', 69 | expected_data = { 70 | event: 'test', 71 | properties: { 72 | token: 'token', 73 | }, 74 | }; 75 | 76 | mixpanel.send_request.mockImplementationOnce((_, cb) => cb(undefined)); 77 | 78 | const callback = vi.fn(); 79 | mixpanel.track('test', callback); 80 | expect(callback).toHaveBeenCalledWith(undefined); 81 | }); 82 | 83 | it('supports Date object for time', (test) => { 84 | var event = 'test', 85 | time = new Date(mock_now_time), 86 | props = { time: time }, 87 | expected_endpoint = '/track', 88 | expected_data = { 89 | event: 'test', 90 | properties: expect.objectContaining({ 91 | token: 'token', 92 | time: time.getTime(), 93 | mp_lib: 'node', 94 | $lib_version: packageInfo.version 95 | }), 96 | }; 97 | 98 | mixpanel.track(event, props); 99 | 100 | expect(mixpanel.send_request).toHaveBeenCalledWith( 101 | expect.objectContaining({ 102 | endpoint: expected_endpoint, 103 | data: expected_data, 104 | }), 105 | undefined, 106 | ); 107 | }); 108 | 109 | it('supports unix timestamp for time', (test) => { 110 | var event = 'test', 111 | time = mock_now_time, 112 | props = { time: time }, 113 | expected_endpoint = '/track', 114 | expected_data = { 115 | event: 'test', 116 | properties: expect.objectContaining({ 117 | token: 'token', 118 | time: time, 119 | mp_lib: 'node', 120 | $lib_version: packageInfo.version 121 | }), 122 | }; 123 | 124 | mixpanel.track(event, props); 125 | 126 | expect(mixpanel.send_request).toHaveBeenCalledWith( 127 | expect.objectContaining({ 128 | endpoint: expected_endpoint, 129 | data: expected_data, 130 | }), 131 | undefined, 132 | ); 133 | }); 134 | 135 | it('throws error if time is not a number or Date', () => { 136 | var event = 'test', 137 | props = { time: 'not a number or Date' }; 138 | 139 | expect(() => mixpanel.track(event, props)).toThrowError( 140 | /`time` property must be a Date or Unix timestamp/, 141 | ); 142 | }); 143 | 144 | it('does not require time property', (test) => { 145 | var event = 'test', 146 | props = {}; 147 | 148 | expect(() => mixpanel.track(event, props)).not.toThrowError(); 149 | }); 150 | }); 151 | 152 | describe('track_batch', () => { 153 | let mixpanel; 154 | beforeEach(() => { 155 | mixpanel = Mixpanel.init('token'); 156 | vi.useFakeTimers(); 157 | vi.spyOn(mixpanel, 'send_request'); 158 | 159 | return () => { 160 | vi.useRealTimers(); 161 | mixpanel.send_request.mockRestore(); 162 | } 163 | }); 164 | 165 | it('calls send_request with correct endpoint, data, and method', () => { 166 | var expected_endpoint = '/track', 167 | event_list = [ 168 | {event: 'test', properties: { key1: 'val1', time: 500 }}, 169 | {event: 'test', properties: { key2: 'val2', time: 1000}}, 170 | {event: 'test2', properties: { key2: 'val2', time: 1500}}, 171 | ], 172 | expected_data = [ 173 | {event: 'test', properties: { key1: 'val1', time: 500, token: 'token'}}, 174 | {event: 'test', properties: { key2: 'val2', time: 1000, token: 'token'}}, 175 | {event: 'test2', properties: { key2: 'val2', time: 1500, token: 'token'}} 176 | ].map((val) => expect.objectContaining(val)); 177 | 178 | mixpanel.track_batch(event_list); 179 | 180 | expect(mixpanel.send_request).toHaveBeenCalledWith( 181 | { 182 | method: 'POST', 183 | endpoint: expected_endpoint, 184 | data: expected_data, 185 | }, 186 | expect.any(Function), 187 | ); 188 | }); 189 | 190 | it('does not require the time argument for every event', () => { 191 | var event_list = [ 192 | {event: 'test', properties: {key1: 'val1', time: 500 }}, 193 | {event: 'test', properties: {key2: 'val2', time: 1000}}, 194 | {event: 'test2', properties: {key2: 'val2' }} 195 | ]; 196 | expect(() => mixpanel.track_batch(event_list)).not.toThrowError(); 197 | }); 198 | 199 | it('batches 50 events at a time', () => { 200 | var event_list = []; 201 | for (var ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 202 | event_list.push({ 203 | event: 'test', 204 | properties: { key1: 'val1', time: 500 + ei }, 205 | }); 206 | } 207 | 208 | mixpanel.track_batch(event_list); 209 | 210 | expect(mixpanel.send_request).toHaveBeenCalledTimes(3); 211 | }); 212 | }); 213 | 214 | describe('track_batch_integration', () => { 215 | let mixpanel; 216 | let http_emitter; 217 | let res; 218 | let event_list; 219 | beforeEach(() => { 220 | mixpanel = Mixpanel.init('token', { key: 'key' }); 221 | vi.useFakeTimers(); 222 | 223 | vi.spyOn(https, 'request'); 224 | 225 | http_emitter = new events.EventEmitter(); 226 | 227 | // stub sequence of https responses 228 | res = []; 229 | for (let ri = 0; ri < 5; ri++) { 230 | res.push(new events.EventEmitter()); 231 | https.request.mockImplementationOnce((_, cb) => { 232 | cb(res[ri]); 233 | return { 234 | write: function () {}, 235 | end: function () {}, 236 | on: function (event) {}, 237 | }; 238 | }); 239 | } 240 | 241 | event_list = []; 242 | for (var ei = 0; ei < 130; ei++) {// 3 batches: 50 + 50 + 30 243 | event_list.push({event: 'test', properties: { key1: 'val1', time: 500 + ei }}); 244 | } 245 | 246 | return () => { 247 | vi.restoreAllMocks(); 248 | } 249 | }); 250 | 251 | it('calls provided callback after all requests finish', () => { 252 | const callback = vi.fn(); 253 | mixpanel.track_batch(event_list, callback); 254 | for (var ri = 0; ri < 3; ri++) { 255 | res[ri].emit('data', '1'); 256 | res[ri].emit('end'); 257 | } 258 | expect(https.request).toHaveBeenCalledTimes(3); 259 | expect(callback).toHaveBeenCalledTimes(1); 260 | expect(callback).toHaveBeenCalledWith(null, [ 261 | undefined, 262 | undefined, 263 | undefined, 264 | ]); 265 | }); 266 | 267 | it('passes error list to callback', () => { 268 | const callback = vi.fn(); 269 | mixpanel.track_batch(event_list, callback); 270 | for (var ri = 0; ri < 3; ri++) { 271 | res[ri].emit('data', '0'); 272 | res[ri].emit('end'); 273 | } 274 | expect(callback.mock.calls[0][0].length).toBe(3); 275 | }); 276 | 277 | it('calls provided callback when options are passed', () => { 278 | const callback = vi.fn(); 279 | mixpanel.track_batch(event_list, { max_batch_size: 100 }, callback); 280 | for (var ri = 0; ri < 3; ri++) { 281 | res[ri].emit('data', '1'); 282 | res[ri].emit('end'); 283 | } 284 | expect(callback).toHaveBeenCalledTimes(1); 285 | expect(https.request).toHaveBeenCalledTimes(3); 286 | expect(callback).toHaveBeenCalledWith(null, [undefined]); 287 | }); 288 | 289 | it('sends more requests when max_batch_size < 50', (test) => { 290 | const callback = vi.fn(); 291 | mixpanel.track_batch(event_list, { max_batch_size: 30 }, callback); 292 | for (var ri = 0; ri < 5; ri++) { 293 | res[ri].emit('data', '1'); 294 | res[ri].emit('end'); 295 | } 296 | expect(callback).toHaveBeenCalledTimes(1); 297 | expect(https.request).toHaveBeenCalledTimes(5); 298 | expect(callback).toHaveBeenCalledWith(null, [ 299 | undefined, 300 | undefined, 301 | undefined, 302 | undefined, 303 | undefined, 304 | ]); 305 | }); 306 | 307 | it('can set max concurrent requests', (test) => { 308 | const async_all_stub = vi.fn(); 309 | async_all_stub.mockImplementation((_, __, cb) => cb(null)); 310 | const PatchedMixpanel = proxyquire('../lib/mixpanel-node', { 311 | './utils': { async_all: async_all_stub }, 312 | }); 313 | mixpanel = PatchedMixpanel.init('token', { key: 'key' }); 314 | 315 | const callback = vi.fn(); 316 | 317 | mixpanel.track_batch(event_list, { max_batch_size: 30, max_concurrent_requests: 2 }, callback); 318 | for (var ri = 0; ri < 3; ri++) { 319 | res[ri].emit('data', '1'); 320 | res[ri].emit('end'); 321 | } 322 | expect(callback).toHaveBeenCalledTimes(1); 323 | expect(async_all_stub).toHaveBeenCalledTimes(3); 324 | expect(callback).toHaveBeenCalledWith(null, undefined); 325 | }); 326 | 327 | it('behaves well without a callback', () => { 328 | mixpanel.track_batch(event_list); 329 | expect(https.request).toHaveBeenCalledTimes(3); 330 | mixpanel.track_batch(event_list, { max_batch_size: 100 }); 331 | expect(https.request).toHaveBeenCalledTimes(5); 332 | }); 333 | }); 334 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const async_all = require('../lib/utils').async_all; 2 | 3 | describe('async_all', () => { 4 | it('calls callback with empty results if no requests', (done) => { 5 | const requests = []; 6 | const handler_fn = vi.fn((_, cb) => cb()); 7 | const callback = vi.fn(); 8 | 9 | async_all(requests, handler_fn, callback); 10 | expect(callback).toHaveBeenCalledTimes(1); 11 | }); 12 | 13 | it('runs handler for each request and calls callback with results', () => { 14 | const requests = [1, 2, 3]; 15 | const handler_fn = vi.fn() 16 | .mockImplementationOnce((_, cb) => cb(null, 4)) 17 | .mockImplementationOnce((_, cb) => cb(null, 5)) 18 | .mockImplementationOnce((_, cb) => cb(null, 6)); 19 | 20 | const callback = vi.fn(); 21 | 22 | async_all(requests, handler_fn, callback); 23 | expect(handler_fn).toHaveBeenCalledTimes(requests.length); 24 | expect(handler_fn.mock.calls[0][0]).toBe(1); 25 | expect(handler_fn.mock.calls[1][0]).toBe(2); 26 | expect(handler_fn.mock.calls[2][0]).toBe(3); 27 | expect(callback).toHaveBeenCalledTimes(1); 28 | expect(callback).toHaveBeenCalledWith(null, [4, 5, 6]); 29 | }); 30 | 31 | it('calls callback with errors and results from handler', () => { 32 | const requests = [1, 2, 3]; 33 | const handler_fn = vi.fn() 34 | .mockImplementationOnce((_, cb) => cb('error1', null)) 35 | .mockImplementationOnce((_, cb) => cb('error2', null)) 36 | .mockImplementationOnce((_, cb) => cb(null, 6)); 37 | const callback = vi.fn(); 38 | 39 | async_all(requests, handler_fn, callback); 40 | expect(handler_fn).toHaveBeenCalledTimes(requests.length); 41 | expect(handler_fn.mock.calls[0][0]).toBe(1); 42 | expect(handler_fn.mock.calls[1][0]).toBe(2); 43 | expect(handler_fn.mock.calls[2][0]).toBe(3); 44 | expect(callback).toHaveBeenCalledTimes(1); 45 | expect(callback).toHaveBeenCalledWith(['error1', 'error2'], [null, null, 6]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import {coverageConfigDefaults, defineConfig} from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | include: [ 7 | 'test/**.js' 8 | ], 9 | coverage: { 10 | exclude: [ 11 | ...coverageConfigDefaults.exclude, 12 | 'example.js' 13 | ] 14 | } 15 | }, 16 | }) 17 | --------------------------------------------------------------------------------