make-mp-data 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +2 -0
- package/.prettierrc +0 -0
- package/.vscode/launch.json +19 -0
- package/.vscode/settings.json +5 -0
- package/README.md +30 -0
- package/cli.js +74 -0
- package/data/.gitkeep +0 -0
- package/default.js +152 -0
- package/index.js +395 -0
- package/package.json +41 -0
- package/utils.js +226 -0
package/.gitattributes
ADDED
package/.prettierrc
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Use IntelliSense to learn about possible attributes.
|
|
3
|
+
// Hover to view descriptions of existing attributes.
|
|
4
|
+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
5
|
+
"version": "0.2.0",
|
|
6
|
+
"configurations": [
|
|
7
|
+
{
|
|
8
|
+
"type": "node",
|
|
9
|
+
"request": "launch",
|
|
10
|
+
"name": "go",
|
|
11
|
+
"runtimeExecutable": "nodemon",
|
|
12
|
+
"program": "${file}",
|
|
13
|
+
"restart": true,
|
|
14
|
+
"console": "integratedTerminal",
|
|
15
|
+
"internalConsoleOptions": "neverOpen",
|
|
16
|
+
"args": ["--ignore", "./data/"]
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# Make Mixpanel Data
|
|
4
|
+
a quick and dirty CLI in node.js to generate fake data for mixpanel.
|
|
5
|
+
|
|
6
|
+
## tldr;
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx make-mp-data --token 1234
|
|
10
|
+
```
|
|
11
|
+
- makes events, users, groups (sends them to your project)
|
|
12
|
+
- makes lookup tables and SCD type 2 exported as CSVs
|
|
13
|
+
|
|
14
|
+
(note: if you want group analytics, add a `company_id` group key to your project before running)
|
|
15
|
+
|
|
16
|
+
## customization
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx make-mp-data [dataModel.js] [options]
|
|
20
|
+
```
|
|
21
|
+
ex.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx make-mp-data ecommSpec.js --token 1234 --numDays 30 --numUsers 1000 --numEvents 1000000
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
see `--help` for a full list of options
|
|
28
|
+
|
|
29
|
+
see `./examples/` for a few `dataModel.js`` examples
|
|
30
|
+
|
package/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const yargs = require('yargs');
|
|
2
|
+
const { version } = require('./package.json');
|
|
3
|
+
|
|
4
|
+
const hero = String.raw`
|
|
5
|
+
|
|
6
|
+
┏┳┓┏┓ ┏┫┏┓╋┏┓ ┏┓┏┓┏┓┏┓┏┓┏┓╋┏┓┏┓
|
|
7
|
+
┛┗┗┣┛ ┗┻┗┻┗┗┻ ┗┫┗ ┛┗┗ ┛ ┗┻┗┗┛┛
|
|
8
|
+
┛ ┛
|
|
9
|
+
makes all the things for mixpanel (v${version || 1})
|
|
10
|
+
by ak@mixpanel.com
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
console.log(hero);
|
|
14
|
+
|
|
15
|
+
function cliParams() {
|
|
16
|
+
const args = yargs(process.argv.splice(2))
|
|
17
|
+
.scriptName("make-mp-data")
|
|
18
|
+
.usage(`\nusage:\nnpx $0 [dataModel.js] [options]
|
|
19
|
+
ex:
|
|
20
|
+
npx $0 --token 1234 --events 1000000
|
|
21
|
+
npx $0 myDataOutline.js --token 1234 --days 90
|
|
22
|
+
|
|
23
|
+
DOCS: https://github.com/ak--47/make-mp-data`)
|
|
24
|
+
.command('$0', 'model mixpanel data', () => { })
|
|
25
|
+
.option("token", {
|
|
26
|
+
demandOption: false,
|
|
27
|
+
describe: 'project token; if supplied data will be sent to mixpanel',
|
|
28
|
+
type: 'string'
|
|
29
|
+
})
|
|
30
|
+
.option("seed", {
|
|
31
|
+
demandOption: false,
|
|
32
|
+
describe: 'randomness seed; used to create distinct_ids',
|
|
33
|
+
type: 'string'
|
|
34
|
+
})
|
|
35
|
+
.option("format", {
|
|
36
|
+
demandOption: false,
|
|
37
|
+
default: 'csv',
|
|
38
|
+
describe: 'csv or json',
|
|
39
|
+
type: 'string'
|
|
40
|
+
})
|
|
41
|
+
.option("numDays", {
|
|
42
|
+
demandOption: false,
|
|
43
|
+
describe: 'number of days in past to model',
|
|
44
|
+
type: 'number',
|
|
45
|
+
})
|
|
46
|
+
.option("numUsers", {
|
|
47
|
+
demandOption: false,
|
|
48
|
+
describe: 'number of users to model',
|
|
49
|
+
type: 'number',
|
|
50
|
+
})
|
|
51
|
+
.option("numEvents", {
|
|
52
|
+
demandOption: false,
|
|
53
|
+
describe: 'number of events to model',
|
|
54
|
+
type: 'number',
|
|
55
|
+
})
|
|
56
|
+
.option("region", {
|
|
57
|
+
demandOption: false,
|
|
58
|
+
default: 'US',
|
|
59
|
+
describe: 'either US or EU',
|
|
60
|
+
type: 'string'
|
|
61
|
+
})
|
|
62
|
+
.help()
|
|
63
|
+
.wrap(null)
|
|
64
|
+
.argv;
|
|
65
|
+
|
|
66
|
+
// if (args._.length === 0 && !args.type?.toLowerCase()?.includes('export')) {
|
|
67
|
+
// yargs.showHelp();
|
|
68
|
+
// process.exit();
|
|
69
|
+
// }
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
module.exports = cliParams;
|
package/data/.gitkeep
ADDED
|
File without changes
|
package/default.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const Chance = require('chance');
|
|
2
|
+
const chance = new Chance();
|
|
3
|
+
const { weightedRange, makeProducts, date } = require('./utils.js');
|
|
4
|
+
|
|
5
|
+
const config = {
|
|
6
|
+
token: "",
|
|
7
|
+
seed: "foo bar baz",
|
|
8
|
+
numDays: 30, //how many days worth of data
|
|
9
|
+
numEvents: 250000, //how many events
|
|
10
|
+
numUsers: 1500, //how many users
|
|
11
|
+
format: 'csv', //csv or json
|
|
12
|
+
region: "US",
|
|
13
|
+
|
|
14
|
+
events: [
|
|
15
|
+
{
|
|
16
|
+
"event": "checkout",
|
|
17
|
+
"weight": 2,
|
|
18
|
+
"properties": {
|
|
19
|
+
amount: weightedRange(5, 500, 1000, .25),
|
|
20
|
+
currency: ["USD", "CAD", "EUR", "BTC", "ETH", "JPY"],
|
|
21
|
+
cart: makeProducts,
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"event": "add to cart",
|
|
26
|
+
"weight": 4,
|
|
27
|
+
"properties": {
|
|
28
|
+
isFeaturedItem: [true, false, false],
|
|
29
|
+
amount: weightedRange(5, 500, 1000, .25),
|
|
30
|
+
rating: weightedRange(1, 5),
|
|
31
|
+
reviews: weightedRange(0, 35),
|
|
32
|
+
product_id: weightedRange(1, 1000)
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"event": "page view",
|
|
37
|
+
"weight": 10,
|
|
38
|
+
"properties": {
|
|
39
|
+
path: ["/", "/", "/help", "/account", "/watch", "/listen", "/product", "/people", "/peace"],
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"event": "view item",
|
|
44
|
+
"weight": 8,
|
|
45
|
+
"properties": {
|
|
46
|
+
product_id: weightedRange(1, 1000),
|
|
47
|
+
colors: ["light", "dark", "custom", "dark"]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"event": "save item",
|
|
52
|
+
"weight": 5,
|
|
53
|
+
"properties": {
|
|
54
|
+
product_id: weightedRange(1, 1000),
|
|
55
|
+
colors: ["light", "dark", "custom", "dark"]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"event": "sign up",
|
|
60
|
+
"isFirstEvent": true,
|
|
61
|
+
"weight": 0,
|
|
62
|
+
"properties": {
|
|
63
|
+
variant: ["A", "B", "C", "Control"],
|
|
64
|
+
experiment: ["no password", "social sign in", "new tutorial"],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
superProps: {
|
|
69
|
+
platform: ["web", "mobile", "kiosk"],
|
|
70
|
+
|
|
71
|
+
},
|
|
72
|
+
/*
|
|
73
|
+
user properties work the same as event properties
|
|
74
|
+
each key should be an array or function reference
|
|
75
|
+
*/
|
|
76
|
+
userProps: {
|
|
77
|
+
title: chance.profession.bind(chance),
|
|
78
|
+
luckyNumber: weightedRange(42, 420),
|
|
79
|
+
servicesUsed: [["foo"], ["foo", "bar"], ["foo", "bar", "baz"], ["foo", "bar", "baz", "qux"], ["baz", "qux"], ["qux"]],
|
|
80
|
+
spiritAnimal: chance.animal.bind(chance)
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
scdProps: {
|
|
84
|
+
plan: ["free", "free", "free", "basic", "basic", "premium", "enterprise"],
|
|
85
|
+
MRR: weightedRange(0, 10000, 1000, .15),
|
|
86
|
+
NPS: weightedRange(0, 10, 150, 2),
|
|
87
|
+
marketingOptIn: [true, true, false],
|
|
88
|
+
dateOfRenewal: date(100, false),
|
|
89
|
+
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
for group analytics keys, we need an array of arrays [[],[],[]]
|
|
94
|
+
each pair represents a group_key and the number of profiles for that key
|
|
95
|
+
*/
|
|
96
|
+
groupKeys: [
|
|
97
|
+
['company_id', 250],
|
|
98
|
+
|
|
99
|
+
],
|
|
100
|
+
groupProps: {
|
|
101
|
+
company_id: {
|
|
102
|
+
$name: () => { return chance.company(); },
|
|
103
|
+
$email: () => { return `CSM ${chance.pickone(["AK", "Jessica", "Michelle", "Dana", "Brian", "Dave"])}`; },
|
|
104
|
+
"# of employees": weightedRange(3, 10000),
|
|
105
|
+
"sector": ["tech", "finance", "healthcare", "education", "government", "non-profit"],
|
|
106
|
+
"segment": ["enterprise", "SMB", "mid-market"],
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
lookupTables: [
|
|
111
|
+
{
|
|
112
|
+
key: "product_id",
|
|
113
|
+
entries: 1000,
|
|
114
|
+
attributes: {
|
|
115
|
+
category: [
|
|
116
|
+
"Books",
|
|
117
|
+
"Movies",
|
|
118
|
+
"Music",
|
|
119
|
+
"Games",
|
|
120
|
+
"Electronics",
|
|
121
|
+
"Computers",
|
|
122
|
+
"Smart Home",
|
|
123
|
+
"Home",
|
|
124
|
+
"Garden & Tools",
|
|
125
|
+
"Pet Supplies",
|
|
126
|
+
"Food & Grocery",
|
|
127
|
+
"Beauty",
|
|
128
|
+
"Health",
|
|
129
|
+
"Toys",
|
|
130
|
+
"Kids",
|
|
131
|
+
"Baby",
|
|
132
|
+
"Handmade",
|
|
133
|
+
"Sports",
|
|
134
|
+
"Outdoors",
|
|
135
|
+
"Automotive",
|
|
136
|
+
"Industrial",
|
|
137
|
+
"Entertainment",
|
|
138
|
+
"Art"
|
|
139
|
+
],
|
|
140
|
+
"demand": ["high", "medium", "medium", "low"],
|
|
141
|
+
"supply": ["high", "medium", "medium", "low"],
|
|
142
|
+
"manufacturer": chance.company.bind(chance),
|
|
143
|
+
"price": weightedRange(5, 500, 1000, .25),
|
|
144
|
+
"rating": weightedRange(1, 5),
|
|
145
|
+
"reviews": weightedRange(0, 35)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
module.exports = config;
|
package/index.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/*
|
|
2
|
+
make fake mixpanel data easily!
|
|
3
|
+
by AK
|
|
4
|
+
ak@mixpanel.com
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const mp = require("mixpanel-import");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const Chance = require("chance");
|
|
10
|
+
const chance = new Chance();
|
|
11
|
+
const { touch, mkdir, comma, bytesHuman } = require("ak-tools");
|
|
12
|
+
const Papa = require("papaparse");
|
|
13
|
+
const {
|
|
14
|
+
integer,
|
|
15
|
+
pick,
|
|
16
|
+
weightedRange,
|
|
17
|
+
progress,
|
|
18
|
+
person,
|
|
19
|
+
choose,
|
|
20
|
+
openFinder
|
|
21
|
+
} = require("./utils.js");
|
|
22
|
+
const dayjs = require("dayjs");
|
|
23
|
+
const utc = require("dayjs/plugin/utc");
|
|
24
|
+
const cliParams = require('./cli.js');
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
dayjs.extend(utc);
|
|
28
|
+
Array.prototype.pickOne = pick;
|
|
29
|
+
const now = dayjs().unix();
|
|
30
|
+
const dayInSec = 86400;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
const args = cliParams();
|
|
35
|
+
const {
|
|
36
|
+
token,
|
|
37
|
+
seed,
|
|
38
|
+
format,
|
|
39
|
+
numDays,
|
|
40
|
+
numUsers,
|
|
41
|
+
numEvents,
|
|
42
|
+
region
|
|
43
|
+
} = args;
|
|
44
|
+
const suppliedConfig = args._[0];
|
|
45
|
+
|
|
46
|
+
//if the user specifics an separate config file
|
|
47
|
+
let config = null;
|
|
48
|
+
if (suppliedConfig) {
|
|
49
|
+
console.log(`using ${suppliedConfig} for data\n`);
|
|
50
|
+
config = require(path.resolve(suppliedConfig));
|
|
51
|
+
} else {
|
|
52
|
+
console.log(`... using default configuration ...\n`);
|
|
53
|
+
config = require(path.resolve("./default.js"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//override config with cli params
|
|
57
|
+
if (token) config.token = token;
|
|
58
|
+
if (seed) config.seed = seed;
|
|
59
|
+
if (format) config.format = format;
|
|
60
|
+
if (numDays) config.numDays = numDays;
|
|
61
|
+
if (numUsers) config.numUsers = numUsers;
|
|
62
|
+
if (numEvents) config.numEvents = numEvents;
|
|
63
|
+
if (region) config.region = region;
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
//our main program
|
|
67
|
+
async function main(config) {
|
|
68
|
+
|
|
69
|
+
const {
|
|
70
|
+
seed = "every time a rug is micturated upon in this fair city...",
|
|
71
|
+
numEvents = 100000,
|
|
72
|
+
numUsers = 1000,
|
|
73
|
+
numDays = 30,
|
|
74
|
+
events = [{ event: "foo" }, { event: "bar" }, { event: "baz" }],
|
|
75
|
+
superProps = { platform: ["web", "iOS", "Android"] },
|
|
76
|
+
userProps = {
|
|
77
|
+
favoriteColor: ["red", "green", "blue", "yellow"],
|
|
78
|
+
spiritAnimal: chance.animal,
|
|
79
|
+
},
|
|
80
|
+
scdProps = { NPS: weightedRange(0, 10, 150, 1.6) },
|
|
81
|
+
groupKeys = [],
|
|
82
|
+
groupProps = {},
|
|
83
|
+
lookupTables = [],
|
|
84
|
+
format = "csv",
|
|
85
|
+
token = null,
|
|
86
|
+
region = "US",
|
|
87
|
+
} = config;
|
|
88
|
+
|
|
89
|
+
const uuidChance = new Chance(seed);
|
|
90
|
+
|
|
91
|
+
//the function which generates $distinct_id
|
|
92
|
+
function uuid() {
|
|
93
|
+
const distinct_id = uuidChance.guid();
|
|
94
|
+
const daysAgoBorn = chance.integer({ min: 1, max: numDays });
|
|
95
|
+
return {
|
|
96
|
+
distinct_id,
|
|
97
|
+
...person(daysAgoBorn),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// weigh events for random selection
|
|
102
|
+
const weightedEvents = events
|
|
103
|
+
.reduce((acc, event) => {
|
|
104
|
+
for (let i = 0; i < event.weight; i++) {
|
|
105
|
+
acc.push(event);
|
|
106
|
+
}
|
|
107
|
+
return acc;
|
|
108
|
+
}, [])
|
|
109
|
+
.filter((e) => e.weight > 0)
|
|
110
|
+
.filter((e) => !e.isFirstEvent);
|
|
111
|
+
|
|
112
|
+
const firstEvents = events.filter((e) => e.isFirstEvent);
|
|
113
|
+
const eventData = [];
|
|
114
|
+
const userProfilesData = [];
|
|
115
|
+
const scdTableData = [];
|
|
116
|
+
const groupProfilesData = [];
|
|
117
|
+
const lookupTableData = [];
|
|
118
|
+
const avgEvPerUser = Math.floor(numEvents / numUsers);
|
|
119
|
+
|
|
120
|
+
//user loop
|
|
121
|
+
for (let i = 1; i < numUsers + 1; i++) {
|
|
122
|
+
progress("users", i);
|
|
123
|
+
const user = uuid();
|
|
124
|
+
const { distinct_id, $created } = user;
|
|
125
|
+
userProfilesData.push(makeProfile(userProps, user));
|
|
126
|
+
const mutations = chance.integer({ min: 1, max: 20 });
|
|
127
|
+
scdTableData.push(makeSCD(scdProps, distinct_id, mutations, $created));
|
|
128
|
+
const numEventsThisUser = Math.round(
|
|
129
|
+
chance.normal({ mean: avgEvPerUser, dev: avgEvPerUser / 4 })
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (firstEvents) {
|
|
133
|
+
eventData.push(
|
|
134
|
+
makeEvent(
|
|
135
|
+
distinct_id,
|
|
136
|
+
dayjs($created).unix(),
|
|
137
|
+
firstEvents,
|
|
138
|
+
superProps,
|
|
139
|
+
groupKeys,
|
|
140
|
+
true
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
//event loop
|
|
146
|
+
for (let j = 0; j < numEventsThisUser; j++) {
|
|
147
|
+
eventData.push(
|
|
148
|
+
makeEvent(
|
|
149
|
+
distinct_id,
|
|
150
|
+
dayjs($created).unix(),
|
|
151
|
+
weightedEvents,
|
|
152
|
+
superProps,
|
|
153
|
+
groupKeys
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
console.log("\n");
|
|
159
|
+
|
|
160
|
+
// make group profiles
|
|
161
|
+
for (const groupPair of groupKeys) {
|
|
162
|
+
const groupKey = groupPair[0];
|
|
163
|
+
const groupCardinality = groupPair[1];
|
|
164
|
+
const groupProfiles = [];
|
|
165
|
+
for (let i = 1; i < groupCardinality + 1; i++) {
|
|
166
|
+
progress("groups", i);
|
|
167
|
+
const group = {
|
|
168
|
+
[groupKey]: i,
|
|
169
|
+
...makeProfile(groupProps[groupKey]),
|
|
170
|
+
$distinct_id: i
|
|
171
|
+
};
|
|
172
|
+
groupProfiles.push(group);
|
|
173
|
+
}
|
|
174
|
+
groupProfilesData.push({ key: groupKey, data: groupProfiles });
|
|
175
|
+
}
|
|
176
|
+
console.log("\n");
|
|
177
|
+
|
|
178
|
+
// make lookup tables
|
|
179
|
+
for (const lookupTable of lookupTables) {
|
|
180
|
+
const { key, entries, attributes } = lookupTable;
|
|
181
|
+
const data = [];
|
|
182
|
+
for (let i = 1; i < entries + 1; i++) {
|
|
183
|
+
progress("lookups", i);
|
|
184
|
+
const item = {
|
|
185
|
+
[key]: i,
|
|
186
|
+
...makeProfile(attributes),
|
|
187
|
+
};
|
|
188
|
+
data.push(item);
|
|
189
|
+
}
|
|
190
|
+
lookupTableData.push({ key, data });
|
|
191
|
+
}
|
|
192
|
+
const { eventFiles, userFiles, scdFiles, groupFiles, lookupFiles, folder } = buildFileNames(config);
|
|
193
|
+
const pairs = [
|
|
194
|
+
[eventFiles, eventData],
|
|
195
|
+
[userFiles, userProfilesData],
|
|
196
|
+
[scdFiles, scdTableData],
|
|
197
|
+
[groupFiles, groupProfilesData],
|
|
198
|
+
[lookupFiles, lookupTableData],
|
|
199
|
+
];
|
|
200
|
+
console.log("\n");
|
|
201
|
+
//write the files
|
|
202
|
+
for (const pair of pairs) {
|
|
203
|
+
const [paths, data] = pair;
|
|
204
|
+
for (const path of paths) {
|
|
205
|
+
if (format === "csv") {
|
|
206
|
+
console.log(`writing ${path}`);
|
|
207
|
+
const csv = Papa.unparse(data, {});
|
|
208
|
+
await touch(path, csv);
|
|
209
|
+
console.log(`\tdone\n`);
|
|
210
|
+
} else {
|
|
211
|
+
await touch(path, data, true);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const importResults = { events: {}, users: {}, groups: [] };
|
|
217
|
+
/** @type {import('mixpanel-import').Creds} */
|
|
218
|
+
const creds = { token };
|
|
219
|
+
/** @type {import('mixpanel-import').Options} */
|
|
220
|
+
const importOpts = {
|
|
221
|
+
fixData: true,
|
|
222
|
+
verbose: false,
|
|
223
|
+
forceStream: true,
|
|
224
|
+
strict: false,
|
|
225
|
+
dryRun: false,
|
|
226
|
+
abridged: false,
|
|
227
|
+
region
|
|
228
|
+
|
|
229
|
+
};
|
|
230
|
+
//send to mixpanel
|
|
231
|
+
if (token) {
|
|
232
|
+
if (eventData) {
|
|
233
|
+
console.log(`importing events to mixpanel...`);
|
|
234
|
+
const imported = await mp(creds, eventData, { recordType: "event", ...importOpts });
|
|
235
|
+
console.log(`\tsent ${comma(imported.success)} events\n`);
|
|
236
|
+
importResults.events = imported;
|
|
237
|
+
}
|
|
238
|
+
if (userProfilesData) {
|
|
239
|
+
console.log(`importing user profiles to mixpanel...`);
|
|
240
|
+
const imported = await mp(creds, userProfilesData, { recordType: "user", ...importOpts });
|
|
241
|
+
console.log(`\tsent ${comma(imported.success)} user profiles\n`);
|
|
242
|
+
importResults.users = imported;
|
|
243
|
+
}
|
|
244
|
+
if (groupProfilesData) {
|
|
245
|
+
|
|
246
|
+
for (const groupProfiles of groupProfilesData) {
|
|
247
|
+
const groupKey = groupProfiles.key;
|
|
248
|
+
const data = groupProfiles.data;
|
|
249
|
+
console.log(`importing ${groupKey} profiles to mixpanel...`);
|
|
250
|
+
const imported = await mp({ token, groupKey }, data, { recordType: "group", ...importOpts });
|
|
251
|
+
console.log(`\tsent ${comma(imported.success)} ${groupKey} profiles\n`);
|
|
252
|
+
importResults.groups.push(imported);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
console.log(`\n\n`);
|
|
256
|
+
}
|
|
257
|
+
return {import: importResults, files: [ eventFiles, userFiles, scdFiles, groupFiles, lookupFiles, folder ]};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function makeProfile(props, defaults) {
|
|
261
|
+
//build the spec
|
|
262
|
+
const profile = {
|
|
263
|
+
...defaults,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
for (const key in props) {
|
|
267
|
+
try {
|
|
268
|
+
profile[key] = choose(props[key]);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
// debugger;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return profile;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function makeSCD(props, distinct_id, mutations, $created) {
|
|
278
|
+
const scdEntries = [];
|
|
279
|
+
let lastInserted = dayjs($created);
|
|
280
|
+
const deltaDays = dayjs().diff(lastInserted, "day");
|
|
281
|
+
|
|
282
|
+
for (let i = 0; i < mutations; i++) {
|
|
283
|
+
if (lastInserted.isAfter(dayjs())) break;
|
|
284
|
+
const scd = makeProfile(props, { distinct_id });
|
|
285
|
+
scd.startTime = lastInserted.toISOString();
|
|
286
|
+
lastInserted = lastInserted.add(integer(1, 1000), "seconds");
|
|
287
|
+
scd.insertTime = lastInserted.toISOString();
|
|
288
|
+
scdEntries.push({ ...scd });
|
|
289
|
+
lastInserted = lastInserted
|
|
290
|
+
.add(integer(0, deltaDays), "day")
|
|
291
|
+
.subtract(integer(1, 1000), "seconds");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return scdEntries;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function makeEvent(distinct_id, earliestTime, events, superProps, groupKeys, isFirstEvent = false) {
|
|
298
|
+
let chosenEvent = events.pickOne();
|
|
299
|
+
if (typeof chosenEvent === "string")
|
|
300
|
+
chosenEvent = { event: chosenEvent, properties: {} };
|
|
301
|
+
const event = {
|
|
302
|
+
event: chosenEvent.event,
|
|
303
|
+
distinct_id,
|
|
304
|
+
$source: "AK's fake data generator",
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (isFirstEvent) event.time = earliestTime;
|
|
308
|
+
if (!isFirstEvent) event.time = integer(earliestTime, now);
|
|
309
|
+
|
|
310
|
+
const props = { ...chosenEvent.properties, ...superProps };
|
|
311
|
+
|
|
312
|
+
//iterate through custom properties
|
|
313
|
+
for (const key in props) {
|
|
314
|
+
try {
|
|
315
|
+
event[key] = choose(props[key]);
|
|
316
|
+
} catch (e) {
|
|
317
|
+
debugger;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
//iterate through groups
|
|
322
|
+
for (const groupPair of groupKeys) {
|
|
323
|
+
const groupKey = groupPair[0];
|
|
324
|
+
const groupCardinality = groupPair[1];
|
|
325
|
+
event[groupKey] = weightedRange(1, groupCardinality).pickOne();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return event;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function buildFileNames(config) {
|
|
332
|
+
const { format = "csv", groupKeys = [], lookupTables = [] } = config;
|
|
333
|
+
const extension = format === "csv" ? "csv" : "json";
|
|
334
|
+
const current = dayjs.utc().format("MM-DD-HH");
|
|
335
|
+
const cwd = path.resolve("./");
|
|
336
|
+
const dataDir = path.join(cwd, "data");
|
|
337
|
+
const writeDir = mkdir(dataDir);
|
|
338
|
+
|
|
339
|
+
const writePaths = {
|
|
340
|
+
eventFiles: [path.join(writeDir, `events-${current}.${extension}`)],
|
|
341
|
+
userFiles: [path.join(writeDir, `users-${current}.${extension}`)],
|
|
342
|
+
scdFiles: [path.join(writeDir, `scd-${current}.${extension}`)],
|
|
343
|
+
groupFiles: [],
|
|
344
|
+
lookupFiles: [],
|
|
345
|
+
folder: writeDir,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
for (const groupPair of groupKeys) {
|
|
349
|
+
const groupKey = groupPair[0];
|
|
350
|
+
writePaths.groupFiles.push(
|
|
351
|
+
path.join(writeDir, `group-${groupKey}-${current}.${extension}`)
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const lookupTable of lookupTables) {
|
|
356
|
+
const { key } = lookupTable;
|
|
357
|
+
writePaths.lookupFiles.push(
|
|
358
|
+
path.join(writeDir, `lookup-${key}-${current}.${extension}`)
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return writePaths;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
//that's all folks :)
|
|
366
|
+
main(config)
|
|
367
|
+
.then((data) => {
|
|
368
|
+
console.log(`------------------SUMMARY------------------`);
|
|
369
|
+
const { events, groups, users } = data.import;
|
|
370
|
+
const files = data.files;
|
|
371
|
+
const folder = files.pop()
|
|
372
|
+
const groupBytes = groups.reduce((acc, group) => { return acc + group.bytes; }, 0);
|
|
373
|
+
const groupSuccess = groups.reduce((acc, group) => { return acc + group.success; }, 0);
|
|
374
|
+
const bytes = events.bytes + groupBytes + users.bytes;
|
|
375
|
+
const stats = {
|
|
376
|
+
events: comma(events.success || 0),
|
|
377
|
+
users: comma(users.success || 0),
|
|
378
|
+
groups: comma(groupSuccess || 0),
|
|
379
|
+
bytes: bytesHuman(bytes || 0)
|
|
380
|
+
};
|
|
381
|
+
console.table(stats);
|
|
382
|
+
console.log(`\nfiles written to ${folder}...`);
|
|
383
|
+
console.log("\t" + files.flat().join('\t\n'));
|
|
384
|
+
console.log(`------------------SUMMARY------------------\n\n\n`);
|
|
385
|
+
})
|
|
386
|
+
.catch((e) => {
|
|
387
|
+
console.log(`------------------ERROR------------------`);
|
|
388
|
+
console.error(e);
|
|
389
|
+
console.log(`------------------ERROR------------------`);
|
|
390
|
+
debugger;
|
|
391
|
+
})
|
|
392
|
+
.finally(() => {
|
|
393
|
+
console.log('have a wonderful day :)');
|
|
394
|
+
openFinder(path.resolve("./data"));
|
|
395
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "make-mp-data",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "builds all mixpanel primitives for a given project",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node index.js",
|
|
8
|
+
"prune": "rm ./data/*",
|
|
9
|
+
"go": "sh ./go.sh",
|
|
10
|
+
"post": "npm publish"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/ak--47/make-mp-data.git"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mixpanel",
|
|
18
|
+
"stream",
|
|
19
|
+
"analytics",
|
|
20
|
+
"tracking",
|
|
21
|
+
"server",
|
|
22
|
+
"CLI",
|
|
23
|
+
"datamart",
|
|
24
|
+
"scd 2",
|
|
25
|
+
"dummy data",
|
|
26
|
+
"fake data"
|
|
27
|
+
],
|
|
28
|
+
"author": "ak@mixpanel.com",
|
|
29
|
+
"license": "ISC",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/ak--47/make-mp-data/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/ak--47/make-mp-data#readme",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"ak-tools": "^1.0.51",
|
|
36
|
+
"chance": "^1.1.7",
|
|
37
|
+
"dayjs": "^1.11.10",
|
|
38
|
+
"mixpanel-import": "^2.5.33",
|
|
39
|
+
"yargs": "^17.7.2"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/utils.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const Chance = require('chance');
|
|
2
|
+
const chance = new Chance();
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const { comma } = require('ak-tools');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const dayjs = require('dayjs');
|
|
7
|
+
const utc = require('dayjs/plugin/utc');
|
|
8
|
+
dayjs.extend(utc);
|
|
9
|
+
|
|
10
|
+
function pick() {
|
|
11
|
+
const choice = chance.pickone(this);
|
|
12
|
+
return choice;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function date(inTheLast = 30, isPast = true, format = 'YYYY-MM-DD') {
|
|
16
|
+
const now = dayjs.utc();
|
|
17
|
+
return function () {
|
|
18
|
+
const when = chance.integer({ min: 0, max: inTheLast });
|
|
19
|
+
let then;
|
|
20
|
+
if (isPast) then = now.subtract(when, 'day');
|
|
21
|
+
if (!isPast) then = now.add(when, 'day');
|
|
22
|
+
if (format) return then.format(format);
|
|
23
|
+
if (!format) return then.toISOString();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dates(inTheLast = 30, numPairs = 5, format = 'YYYY-MM-DD') {
|
|
28
|
+
const pairs = [];
|
|
29
|
+
for (let i = 0; i < numPairs; i++) {
|
|
30
|
+
pairs.push([date(inTheLast, format), date(inTheLast, format)]);
|
|
31
|
+
}
|
|
32
|
+
return pairs;
|
|
33
|
+
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function day(start, end) {
|
|
37
|
+
const format = 'YYYY-MM-DD';
|
|
38
|
+
return function (min, max) {
|
|
39
|
+
start = dayjs(start);
|
|
40
|
+
end = dayjs(end);
|
|
41
|
+
const diff = end.diff(start, 'day');
|
|
42
|
+
const delta = chance.integer({ min: min, max: diff });
|
|
43
|
+
const day = start.add(delta, 'day');
|
|
44
|
+
return {
|
|
45
|
+
start: start.format(format),
|
|
46
|
+
end: end.format(format),
|
|
47
|
+
day: day.format(format)
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function choose(value) {
|
|
54
|
+
if (typeof value === 'function') {
|
|
55
|
+
return value();
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
return chance.pickone(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function exhaust(arr) {
|
|
65
|
+
return function () {
|
|
66
|
+
return arr.shift();
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
function integer(min, max) {
|
|
72
|
+
if (min > max) {
|
|
73
|
+
return chance.integer({
|
|
74
|
+
min: max,
|
|
75
|
+
max: min
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return chance.integer({
|
|
80
|
+
min: min,
|
|
81
|
+
max: max
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeProducts() {
|
|
86
|
+
let categories = ["Device Accessories", "eBooks", "Automotive", "Baby Products", "Beauty", "Books", "Camera & Photo", "Cell Phones & Accessories", "Collectible Coins", "Consumer Electronics", "Entertainment Collectibles", "Fine Art", "Grocery & Gourmet Food", "Health & Personal Care", "Home & Garden", "Independent Design", "Industrial & Scientific", "Accessories", "Major Appliances", "Music", "Musical Instruments", "Office Products", "Outdoors", "Personal Computers", "Pet Supplies", "Software", "Sports", "Sports Collectibles", "Tools & Home Improvement", "Toys & Games", "Video, DVD & Blu-ray", "Video Games", "Watches"];
|
|
87
|
+
let slugs = ['/sale/', '/featured/', '/home/', '/search/', '/wishlist/', '/'];
|
|
88
|
+
let assetExtension = ['.png', '.jpg', '.jpeg', '.heic', '.mp4', '.mov', '.avi'];
|
|
89
|
+
let data = [];
|
|
90
|
+
let numOfItems = integer(1, 12);
|
|
91
|
+
|
|
92
|
+
for (var i = 0; i < numOfItems; i++) {
|
|
93
|
+
|
|
94
|
+
let category = chance.pickone(categories);
|
|
95
|
+
let slug = chance.pickone(slugs);
|
|
96
|
+
let asset = chance.pickone(assetExtension);
|
|
97
|
+
let product_id = chance.guid();
|
|
98
|
+
let price = integer(1, 300);
|
|
99
|
+
let quantity = integer(1, 5);
|
|
100
|
+
|
|
101
|
+
let item = {
|
|
102
|
+
product_id: product_id,
|
|
103
|
+
sku: integer(11111, 99999),
|
|
104
|
+
amount: price,
|
|
105
|
+
quantity: quantity,
|
|
106
|
+
value: price * quantity,
|
|
107
|
+
featured: chance.pickone([true, false]),
|
|
108
|
+
category: category,
|
|
109
|
+
urlSlug: slug + category,
|
|
110
|
+
asset: `${category}-${integer(1, 20)}${asset}`
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
data.push(item);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return data;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Box-Muller transform to generate standard normally distributed values
|
|
120
|
+
function boxMullerRandom() {
|
|
121
|
+
let u = 0, v = 0;
|
|
122
|
+
while (u === 0) u = Math.random();
|
|
123
|
+
while (v === 0) v = Math.random();
|
|
124
|
+
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Apply skewness to the value
|
|
128
|
+
function applySkew(value, skew) {
|
|
129
|
+
if (skew === 1) return value;
|
|
130
|
+
// Adjust the value based on skew
|
|
131
|
+
let sign = value < 0 ? -1 : 1;
|
|
132
|
+
return sign * Math.pow(Math.abs(value), skew);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Map standard normal value to our range
|
|
136
|
+
function mapToRange(value, mean, sd) {
|
|
137
|
+
return Math.round(value * sd + mean);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function weightedRange(min, max, size = 100, skew = 1) {
|
|
141
|
+
const mean = (max + min) / 2;
|
|
142
|
+
const sd = (max - min) / 4;
|
|
143
|
+
let array = [];
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < size; i++) {
|
|
146
|
+
let normalValue = boxMullerRandom();
|
|
147
|
+
let skewedValue = applySkew(normalValue, skew);
|
|
148
|
+
let mappedValue = mapToRange(skewedValue, mean, sd);
|
|
149
|
+
|
|
150
|
+
// Ensure the mapped value is within our min-max range
|
|
151
|
+
if (mappedValue >= min && mappedValue <= max) {
|
|
152
|
+
array.push(mappedValue);
|
|
153
|
+
} else {
|
|
154
|
+
i--; // If out of range, redo this iteration
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return array;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function progress(thing, p) {
|
|
162
|
+
readline.cursorTo(process.stdout, 0);
|
|
163
|
+
process.stdout.write(`${thing} processed ... ${comma(p)}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
function person(bornDaysAgo = 30) {
|
|
168
|
+
//names and photos
|
|
169
|
+
const gender = chance.pickone(['male', 'female']);
|
|
170
|
+
const first = chance.first({ gender });
|
|
171
|
+
const last = chance.last();
|
|
172
|
+
const $name = `${first} ${last}`;
|
|
173
|
+
const $email = `${first[0]}.${last}@${chance.domain()}.com`;
|
|
174
|
+
const avatarPrefix = `https://randomuser.me/api/portraits`;
|
|
175
|
+
const randomAvatarNumber = chance.integer({
|
|
176
|
+
min: 1,
|
|
177
|
+
max: 99
|
|
178
|
+
});
|
|
179
|
+
const avPath = gender === 'male' ? `/men/${randomAvatarNumber}.jpg` : `/women/${randomAvatarNumber}.jpg`;
|
|
180
|
+
const $avatar = avatarPrefix + avPath;
|
|
181
|
+
const $created = date(bornDaysAgo, true, null)();
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
$name,
|
|
185
|
+
$email,
|
|
186
|
+
$avatar,
|
|
187
|
+
$created
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function range(a, b, step = 1) {
|
|
192
|
+
step = !step ? 1 : step;
|
|
193
|
+
b = b / step;
|
|
194
|
+
for (var i = a; i <= b; i++) {
|
|
195
|
+
this.push(i * step);
|
|
196
|
+
}
|
|
197
|
+
return this;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
//helper to open the finder
|
|
202
|
+
function openFinder(path, callback) {
|
|
203
|
+
path = path || '/';
|
|
204
|
+
let p = spawn('open', [path]);
|
|
205
|
+
p.on('error', (err) => {
|
|
206
|
+
p.kill();
|
|
207
|
+
return callback(err);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
weightedRange,
|
|
215
|
+
pick,
|
|
216
|
+
day,
|
|
217
|
+
integer,
|
|
218
|
+
makeProducts,
|
|
219
|
+
date,
|
|
220
|
+
progress,
|
|
221
|
+
person,
|
|
222
|
+
choose,
|
|
223
|
+
range,
|
|
224
|
+
exhaust,
|
|
225
|
+
openFinder
|
|
226
|
+
};
|