make-mp-data 1.3.4 → 1.4.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/index.js CHANGED
@@ -6,25 +6,29 @@ by AK
6
6
  ak@mixpanel.com
7
7
  */
8
8
 
9
- const RUNTIME = process.env.RUNTIME || "unspecified";
10
- const mp = require("mixpanel-import");
11
- const path = require("path");
12
- const Chance = require("chance");
13
- const chance = new Chance();
14
- const { comma, bytesHuman, mkdir, makeName, md5, clone, tracker, uid } = require("ak-tools");
15
- const u = require("./utils.js");
16
- const AKsTimeSoup = require("./timesoup.js");
17
9
  const dayjs = require("dayjs");
18
10
  const utc = require("dayjs/plugin/utc");
19
11
  dayjs.extend(utc);
12
+ const NOW = dayjs('2024-02-02').unix(); //this is a FIXED POINT and we will shift it later
13
+ global.NOW = NOW;
14
+ const mp = require("mixpanel-import");
15
+ const path = require("path");
16
+ const { comma, bytesHuman, makeName, md5, clone, tracker, uid } = require("ak-tools");
17
+ const { generateLineChart } = require('./chart.js');
18
+ const { version } = require('./package.json');
19
+ const os = require("os");
20
+ const metrics = tracker("make-mp-data", "db99eb8f67ae50949a13c27cacf57d41", os.userInfo().username);
21
+
22
+
23
+ const u = require("./utils.js");
20
24
  const cliParams = require("./cli.js");
21
- const NOW = dayjs().unix();
25
+
22
26
  let VERBOSE = false;
23
27
  let isCLI = false;
28
+ let CONFIG;
29
+ require('dotenv').config();
30
+
24
31
 
25
- const { version } = require('./package.json');
26
- const os = require("os");
27
- const metrics = tracker("make-mp-data", "db99eb8f67ae50949a13c27cacf57d41", os.userInfo().username);
28
32
  function track(name, props, ...rest) {
29
33
  if (process.env.NODE_ENV === 'test') return;
30
34
  metrics(name, props, ...rest);
@@ -32,19 +36,33 @@ function track(name, props, ...rest) {
32
36
 
33
37
  /** @typedef {import('./types.d.ts').Config} Config */
34
38
  /** @typedef {import('./types.d.ts').EventConfig} EventConfig */
39
+ /** @typedef {import('./types.d.ts').Funnel} Funnel */
40
+ /** @typedef {import('./types.d.ts').Person} Person */
41
+ /** @typedef {import('./types.d.ts').SCDTableRow} SCDTableRow */
42
+ /** @typedef {import('./types.d.ts').UserProfile} UserProfile */
43
+ /** @typedef {import('./types.d.ts').EventSpec} EventSpec */
35
44
 
36
45
  /**
37
46
  * generates fake mixpanel data
38
47
  * @param {Config} config
39
48
  */
40
49
  async function main(config) {
50
+ //PARAMS
51
+ const seedWord = process.env.SEED || config.seed || "hello friend!";
52
+ config.seed = seedWord;
53
+ u.initChance(seedWord);
54
+ const chance = u.getChance();
55
+ config.chance = chance;
41
56
  let {
42
- seed = "every time a rug is micturated upon in this fair city...",
57
+ seed,
43
58
  numEvents = 100000,
44
59
  numUsers = 1000,
45
60
  numDays = 30,
61
+ epochStart = 0,
62
+ epochEnd = dayjs().unix(),
46
63
  events = [{ event: "foo" }, { event: "bar" }, { event: "baz" }],
47
64
  superProps = { platform: ["web", "iOS", "Android"] },
65
+ funnels = [],
48
66
  userProps = {
49
67
  favoriteColor: ["red", "green", "blue", "yellow"],
50
68
  spiritAnimal: chance.animal.bind(chance),
@@ -61,131 +79,185 @@ async function main(config) {
61
79
  region = "US",
62
80
  writeToDisk = false,
63
81
  verbose = false,
82
+ makeChart = false,
83
+ soup = {},
64
84
  hook = (record) => record,
65
85
  } = config;
86
+ if (!config.superProps) config.superProps = superProps;
87
+ if (!config.userProps || Object.keys(config?.userProps)) config.userProps = userProps;
66
88
  VERBOSE = verbose;
67
89
  config.simulationName = makeName();
68
90
  const { simulationName } = config;
91
+ if (epochStart && !numDays) numDays = dayjs.unix(epochEnd).diff(dayjs.unix(epochStart), "day");
92
+ if (!epochStart && numDays) epochStart = dayjs.unix(epochEnd).subtract(numDays, "day").unix();
93
+ if (epochStart && numDays) { } //noop
94
+ if (!epochStart && !numDays) debugger; //never happens
95
+ config.seed = seed;
96
+ config.numEvents = numEvents;
97
+ config.numUsers = numUsers;
98
+ config.numDays = numDays;
99
+ config.epochStart = epochStart;
100
+ config.epochEnd = epochEnd;
101
+ config.events = events;
102
+ config.superProps = superProps;
103
+ config.funnels = funnels;
104
+ config.userProps = userProps;
105
+ config.scdProps = scdProps;
106
+ config.mirrorProps = mirrorProps;
107
+ config.groupKeys = groupKeys;
108
+ config.groupProps = groupProps;
109
+ config.lookupTables = lookupTables;
110
+ config.anonIds = anonIds;
111
+ config.sessionIds = sessionIds;
112
+ config.format = format;
113
+ config.token = token;
114
+ config.region = region;
115
+ config.writeToDisk = writeToDisk;
116
+ config.verbose = verbose;
117
+ config.makeChart = makeChart;
118
+ config.soup = soup;
119
+ config.hook = hook;
120
+
121
+ //event validation
122
+ const validatedEvents = validateEvents(events);
123
+ events = validatedEvents;
124
+ config.events = validatedEvents;
69
125
  global.MP_SIMULATION_CONFIG = config;
70
- const uuidChance = new Chance(seed);
126
+ CONFIG = config;
71
127
  const runId = uid(42);
72
- track('start simulation', {
73
- runId,
74
- seed,
75
- numEvents,
76
- numUsers,
77
- numDays,
78
- anonIds,
79
- sessionIds,
80
- format,
81
- targetToken: token,
82
- region,
83
- writeToDisk,
84
- isCLI,
85
- version
86
- });
128
+ let trackingParams = { runId, seed, numEvents, numUsers, numDays, anonIds, sessionIds, format, targetToken: token, region, writeToDisk, isCLI, version };
129
+ track('start simulation', trackingParams);
130
+
87
131
  log(`------------------SETUP------------------`);
88
132
  log(`\nyour data simulation will heretofore be known as: \n\n\t${simulationName.toUpperCase()}...\n`);
89
133
  log(`and your configuration is:\n\n`, JSON.stringify({ seed, numEvents, numUsers, numDays, format, token, region, writeToDisk, anonIds, sessionIds }, null, 2));
90
134
  log(`------------------SETUP------------------`, "\n");
91
135
 
92
-
93
- //the function which generates $distinct_id + $anonymous_ids, $session_ids, and $created, skewing towards the present
94
- function generateUser() {
95
- const distinct_id = uuidChance.guid();
96
- let z = u.boxMullerRandom();
97
- const skew = chance.normal({ mean: 10, dev: 3 });
98
- z = u.applySkew(z, skew);
99
-
100
- // Scale and shift the normally distributed value to fit the range of days
101
- const maxZ = u.integer(2, 4);
102
- const scaledZ = (z / maxZ + 1) / 2;
103
- const daysAgoBorn = Math.round(scaledZ * (numDays - 1)) + 1;
104
-
105
- return {
106
- distinct_id,
107
- ...u.person(daysAgoBorn),
108
- };
136
+ //setup all the data structures we will push into
137
+ const eventData = u.enrichArray([], { hook, type: "event", config });
138
+ const userProfilesData = u.enrichArray([], { hook, type: "user", config });
139
+ const scdTableKeys = Object.keys(scdProps);
140
+ const scdTableData = [];
141
+ for (const [index, key] of scdTableKeys.entries()) {
142
+ scdTableData[index] = u.enrichArray([], { hook, type: "scd", config, scdKey: key });
109
143
  }
110
-
111
- // weigh events for random selection
112
- const weightedEvents = events
113
- .reduce((acc, event) => {
114
- const weight = event.weight || 1;
115
- for (let i = 0; i < weight; i++) {
116
-
117
- // @ts-ignore
118
- acc.push(event);
144
+ const groupProfilesData = u.enrichArray([], { hook, type: "group", config });
145
+ const lookupTableData = u.enrichArray([], { hook, type: "lookup", config });
146
+ const avgEvPerUser = Math.ceil(numEvents / numUsers);
147
+
148
+ // if no funnels, make some out of events...
149
+ if (!funnels || !funnels.length) {
150
+ const createdFunnels = [];
151
+ const firstEvents = events.filter((e) => e.isFirstEvent).map((e) => e.event);
152
+ const usageEvents = events.filter((e) => !e.isFirstEvent).map((e) => e.event);
153
+ const numFunnelsToCreate = Math.ceil(usageEvents.length);
154
+ /** @type {Funnel} */
155
+ const funnelTemplate = {
156
+ sequence: [],
157
+ conversionRate: 50,
158
+ order: 'sequential',
159
+ props: {},
160
+ timeToConvert: 1,
161
+ isFirstFunnel: false,
162
+ weight: 1
163
+ };
164
+ if (firstEvents.length) {
165
+ for (const event of firstEvents) {
166
+ createdFunnels.push({ ...clone(funnelTemplate), sequence: [event], isFirstFunnel: true, conversionRate: 100 });
119
167
  }
120
- return acc;
121
- }, [])
168
+ }
122
169
 
123
- // @ts-ignore
124
- .filter((e) => !e.isFirstEvent);
170
+ //at least one funnel with all usage events
171
+ createdFunnels.push({ ...clone(funnelTemplate), sequence: usageEvents });
172
+
173
+ //for the rest, make random funnels
174
+ followUpFunnels: for (let i = 1; i < numFunnelsToCreate; i++) {
175
+ /** @type {Funnel} */
176
+ const funnel = { ...clone(funnelTemplate) };
177
+ funnel.conversionRate = u.integer(25, 75);
178
+ funnel.timeToConvert = u.integer(1, 10);
179
+ funnel.weight = u.integer(1, 10);
180
+ const sequence = u.shuffleArray(usageEvents).slice(0, u.integer(2, usageEvents.length));
181
+ funnel.sequence = sequence;
182
+ funnel.order = 'random';
183
+ createdFunnels.push(funnel);
184
+ }
185
+
186
+ funnels = createdFunnels;
187
+ config.funnels = funnels;
188
+ CONFIG = config;
125
189
 
126
- const firstEvents = events.filter((e) => e.isFirstEvent);
127
- const eventData = enrichArray([], { hook, type: "event", config });
128
- const userProfilesData = enrichArray([], { hook, type: "user", config });
129
- const scdTableKeys = Object.keys(scdProps);
130
- const scdTableData = [];
131
- for (const [index, key] of scdTableKeys.entries()) {
132
- scdTableData[index] = enrichArray([], { hook, type: "scd", config, scdKey: key });
133
190
  }
134
- // const scdTableData = enrichArray([], { hook, type: "scd", config });
135
- const groupProfilesData = enrichArray([], { hook, type: "groups", config });
136
- const lookupTableData = enrichArray([], { hook, type: "lookups", config });
137
- const avgEvPerUser = Math.floor(numEvents / numUsers);
138
191
 
139
192
  //user loop
140
193
  log(`---------------SIMULATION----------------`, "\n\n");
141
- for (let i = 1; i < numUsers + 1; i++) {
194
+ loopUsers: for (let i = 1; i < numUsers + 1; i++) {
142
195
  u.progress("users", i);
143
- const user = generateUser();
144
- const { distinct_id, $created, anonymousIds, sessionIds } = user;
145
- userProfilesData.hPush(makeProfile(userProps, user));
146
-
147
- //scd loop
196
+ const userId = chance.guid();
197
+ // const user = u.generateUser(userId, numDays, amp, freq, skew);
198
+ const user = u.generateUser(userId, numDays);
199
+ const { distinct_id, created, anonymousIds, sessionIds } = user;
200
+ let numEventsPreformed = 0;
201
+
202
+ // profile creation
203
+ const profile = makeProfile(userProps, user);
204
+ userProfilesData.hookPush(profile);
205
+
206
+ //scd creation
207
+ /** @type {Record<string, SCDTableRow[]>} */
208
+ // @ts-ignore
209
+ const userSCD = {};
148
210
  for (const [index, key] of scdTableKeys.entries()) {
149
211
  const mutations = chance.integer({ min: 1, max: 10 });
150
- scdTableData[index].hPush(makeSCD(scdProps[key], key, distinct_id, mutations, $created));
212
+ const changes = makeSCD(scdProps[key], key, distinct_id, mutations, created);
213
+ // @ts-ignore
214
+ userSCD[key] = changes;
215
+ scdTableData[index].hookPush(changes);
151
216
  }
152
217
 
153
- const numEventsThisUser = Math.round(
154
- chance.normal({ mean: avgEvPerUser, dev: avgEvPerUser / u.integer(3, 7) })
155
- );
218
+ let numEventsThisUserWillPreform = Math.floor(chance.normal({
219
+ mean: avgEvPerUser,
220
+ dev: avgEvPerUser / u.integer(u.integer(2, 5), u.integer(2, 7))
221
+ }) * 0.714159265359);
156
222
 
157
- if (firstEvents.length) {
158
- eventData.hPush(
159
- makeEvent(
160
- distinct_id,
161
- anonymousIds,
162
- sessionIds,
163
- dayjs($created).unix(),
164
- firstEvents,
165
- superProps,
166
- groupKeys,
167
- true
168
- )
169
- );
223
+ // power users do 5x more events
224
+ chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
225
+
226
+ // shitty users do 1/3 as many events
227
+ chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
228
+
229
+ numEventsThisUserWillPreform = Math.round(numEventsThisUserWillPreform);
230
+
231
+ let userFirstEventTime;
232
+
233
+ //first funnel
234
+ const firstFunnels = funnels.filter((f) => f.isFirstFunnel).reduce(u.weighFunnels, []);
235
+ const usageFunnels = funnels.filter((f) => !f.isFirstFunnel).reduce(u.weighFunnels, []);
236
+ const userIsBornInDataset = chance.bool({ likelihood: 30 });
237
+ if (firstFunnels.length && userIsBornInDataset) {
238
+ /** @type {Funnel} */
239
+ const firstFunnel = chance.pickone(firstFunnels, user);
240
+
241
+ const [data, userConverted] = makeFunnel(firstFunnel, user, profile, userSCD, null, config);
242
+ userFirstEventTime = dayjs(data[0].time).unix();
243
+ numEventsPreformed += data.length;
244
+ eventData.hookPush(data);
245
+ if (!userConverted) continue loopUsers;
170
246
  }
171
247
 
172
- //event loop
173
- for (let j = 0; j < numEventsThisUser; j++) {
174
- eventData.hPush(
175
- makeEvent(
176
- distinct_id,
177
- anonymousIds,
178
- sessionIds,
179
- dayjs($created).unix(),
180
- weightedEvents,
181
- superProps,
182
- groupKeys
183
- )
184
- );
248
+ while (numEventsPreformed < numEventsThisUserWillPreform) {
249
+ if (usageFunnels.length) {
250
+ /** @type {Funnel} */
251
+ const currentFunnel = chance.pickone(usageFunnels);
252
+ const [data, userConverted] = makeFunnel(currentFunnel, user, profile, userSCD, userFirstEventTime, config);
253
+ numEventsPreformed += data.length;
254
+ eventData.hookPush(data);
255
+ }
185
256
  }
257
+ // end individual user loop
186
258
  }
187
259
 
188
- //flatten SCD
260
+ //flatten SCD tables
189
261
  scdTableData.forEach((table, index) => scdTableData[index] = table.flat());
190
262
 
191
263
  log("\n");
@@ -202,9 +274,10 @@ async function main(config) {
202
274
  ...makeProfile(groupProps[groupKey]),
203
275
  // $distinct_id: i,
204
276
  };
277
+ group["distinct_id"] = i;
205
278
  groupProfiles.push(group);
206
279
  }
207
- groupProfilesData.hPush({ key: groupKey, data: groupProfiles });
280
+ groupProfilesData.hookPush({ key: groupKey, data: groupProfiles });
208
281
  }
209
282
  log("\n");
210
283
 
@@ -220,10 +293,36 @@ async function main(config) {
220
293
  };
221
294
  data.push(item);
222
295
  }
223
- lookupTableData.hPush({ key, data });
296
+ lookupTableData.hookPush({ key, data });
224
297
  }
225
298
 
226
- // deal with mirror props
299
+ // SHIFT TIME
300
+ const actualNow = dayjs();
301
+ const fixedNow = dayjs.unix(global.NOW);
302
+ const timeShift = actualNow.diff(fixedNow, "second");
303
+ const dayShift = actualNow.diff(global.NOW, "day");
304
+ eventData.forEach((event) => {
305
+ const newTime = dayjs(event.time).add(timeShift, "second");
306
+ event.time = newTime.toISOString();
307
+ if (epochStart && newTime.unix() < epochStart) event = {};
308
+ if (epochEnd && newTime.unix() > epochEnd) event = {};
309
+ });
310
+
311
+ userProfilesData.forEach((profile) => {
312
+ const newTime = dayjs(profile.created).add(dayShift, "day");
313
+ profile.created = newTime.toISOString();
314
+ });
315
+
316
+
317
+ // draw charts
318
+ if (makeChart) {
319
+ const bornEvents = config.events?.filter((e) => e.isFirstEvent)?.map(e => e.event) || [];
320
+ const bornFunnels = config.funnels?.filter((f) => f.isFirstFunnel)?.map(f => f.sequence[0]) || [];
321
+ const bornBehaviors = [...bornEvents, ...bornFunnels];
322
+ const chart = await generateLineChart(eventData, bornBehaviors, makeChart);
323
+ }
324
+
325
+ // create mirrorProps
227
326
  let mirrorEventData = [];
228
327
  const mirrorPropKeys = Object.keys(mirrorProps);
229
328
  if (mirrorPropKeys.length) {
@@ -237,7 +336,7 @@ async function main(config) {
237
336
  }
238
337
 
239
338
  const { eventFiles, userFiles, scdFiles, groupFiles, lookupFiles, mirrorFiles, folder } =
240
- buildFileNames(config);
339
+ u.buildFileNames(config);
241
340
  const pairs = [
242
341
  [eventFiles, [eventData]],
243
342
  [userFiles, [userProfilesData]],
@@ -250,20 +349,7 @@ async function main(config) {
250
349
  log(`---------------SIMULATION----------------`, "\n");
251
350
 
252
351
  if (!writeToDisk && !token) {
253
- track('end simulation', {
254
- runId,
255
- seed,
256
- numEvents,
257
- numUsers,
258
- numDays,
259
- anonIds,
260
- sessionIds,
261
- format,
262
- token,
263
- region,
264
- writeToDisk,
265
- isCLI
266
- });
352
+ track('end simulation', trackingParams);
267
353
  return {
268
354
  eventData,
269
355
  userProfilesData,
@@ -271,7 +357,7 @@ async function main(config) {
271
357
  groupProfilesData,
272
358
  lookupTableData,
273
359
  mirrorEventData,
274
- import: {},
360
+ importResults: {},
275
361
  files: []
276
362
  };
277
363
  }
@@ -315,30 +401,28 @@ async function main(config) {
315
401
  const creds = { token };
316
402
  /** @type {import('mixpanel-import').Options} */
317
403
  const commonOpts = {
318
-
319
404
  region,
320
405
  fixData: true,
321
406
  verbose: false,
322
407
  forceStream: true,
323
- strict: false,
408
+ strict: true,
324
409
  dryRun: false,
325
410
  abridged: false,
411
+ fixJson: true,
412
+ showProgress: true
326
413
  };
327
414
 
328
415
  if (eventData) {
329
- log(`importing events to mixpanel...`);
416
+ log(`importing events to mixpanel...\n`);
330
417
  const imported = await mp(creds, eventData, {
331
418
  recordType: "event",
332
- fixData: true,
333
- fixJson: true,
334
- strict: false,
335
419
  ...commonOpts,
336
420
  });
337
421
  log(`\tsent ${comma(imported.success)} events\n`);
338
422
  importResults.events = imported;
339
423
  }
340
424
  if (userProfilesData) {
341
- log(`importing user profiles to mixpanel...`);
425
+ log(`importing user profiles to mixpanel...\n`);
342
426
  const imported = await mp(creds, userProfilesData, {
343
427
  recordType: "user",
344
428
  ...commonOpts,
@@ -350,138 +434,76 @@ async function main(config) {
350
434
  for (const groupProfiles of groupProfilesData) {
351
435
  const groupKey = groupProfiles.key;
352
436
  const data = groupProfiles.data;
353
- log(`importing ${groupKey} profiles to mixpanel...`);
437
+ log(`importing ${groupKey} profiles to mixpanel...\n`);
354
438
  const imported = await mp({ token, groupKey }, data, {
355
439
  recordType: "group",
356
440
  ...commonOpts,
441
+
357
442
  });
358
443
  log(`\tsent ${comma(imported.success)} ${groupKey} profiles\n`);
359
444
 
360
445
  importResults.groups.push(imported);
361
446
  }
362
447
  }
363
-
364
448
  }
365
449
  log(`\n-----------------WRITES------------------`, "\n");
366
- track('end simulation', {
367
- runId,
368
- seed,
369
- numEvents,
370
- numUsers,
371
- numDays,
372
- events,
373
- anonIds,
374
- sessionIds,
375
- format,
376
- targetToken: token,
377
- region,
378
- writeToDisk,
379
- isCLI,
380
- version
381
- });
450
+ track('end simulation', trackingParams);
451
+
382
452
  return {
383
- import: importResults,
453
+ importResults,
384
454
  files: [eventFiles, userFiles, scdFiles, groupFiles, lookupFiles, mirrorFiles, folder],
455
+ eventData,
456
+ userProfilesData,
457
+ scdTableData,
458
+ groupProfilesData,
459
+ lookupTableData,
460
+ mirrorEventData,
385
461
  };
386
462
  }
387
463
 
388
-
389
-
390
-
391
- function makeProfile(props, defaults) {
392
- //build the spec
393
- const profile = {
394
- ...defaults,
395
- };
396
-
397
- // anonymous and session ids
398
- if (!global.MP_SIMULATION_CONFIG?.anonIds) delete profile.anonymousIds;
399
- if (!global.MP_SIMULATION_CONFIG?.sessionIds) delete profile.sessionIds;
400
-
401
- for (const key in props) {
402
- try {
403
- profile[key] = u.choose(props[key]);
404
- } catch (e) {
405
- // debugger;
406
- }
407
- }
408
-
409
- return profile;
410
- }
411
- /**
412
- * @param {import('./types.d.ts').ValueValid} prop
413
- * @param {string} scdKey
414
- * @param {string} distinct_id
415
- * @param {number} mutations
416
- * @param {string} $created
417
- */
418
- function makeSCD(prop, scdKey, distinct_id, mutations, $created) {
419
- if (JSON.stringify(prop) === "{}") return {};
420
- if (JSON.stringify(prop) === "[]") return [];
421
- const scdEntries = [];
422
- let lastInserted = dayjs($created);
423
- const deltaDays = dayjs().diff(lastInserted, "day");
424
-
425
- for (let i = 0; i < mutations; i++) {
426
- if (lastInserted.isAfter(dayjs())) break;
427
- const scd = makeProfile({ [scdKey]: prop }, { distinct_id });
428
- scd.startTime = lastInserted.toISOString();
429
- lastInserted = lastInserted.add(u.integer(1, 1000), "seconds");
430
- scd.insertTime = lastInserted.toISOString();
431
- scdEntries.push({ ...scd });
432
- lastInserted = lastInserted
433
- .add(u.integer(0, deltaDays), "day")
434
- .subtract(u.integer(1, 1000), "seconds");
435
- }
436
-
437
- return scdEntries;
438
- }
439
-
440
464
  /**
441
465
  * creates a random event
442
466
  * @param {string} distinct_id
443
467
  * @param {string[]} anonymousIds
444
468
  * @param {string[]} sessionIds
445
469
  * @param {number} earliestTime
446
- * @param {Object[]} events
470
+ * @param {EventConfig} chosenEvent
447
471
  * @param {Object} superProps
448
472
  * @param {Object} groupKeys
449
473
  * @param {Boolean} isFirstEvent=false
450
474
  */
451
- function makeEvent(distinct_id, anonymousIds, sessionIds, earliestTime, events, superProps, groupKeys, isFirstEvent = false) {
452
- let chosenEvent = chance.pickone(events);
453
-
454
- //allow for a string shorthand
455
- if (typeof chosenEvent === "string") {
456
- chosenEvent = { event: chosenEvent, properties: {} };
457
- }
458
-
475
+ function makeEvent(distinct_id, anonymousIds, sessionIds, earliestTime, chosenEvent, superProps, groupKeys, isFirstEvent = false) {
476
+ const { mean = 0, dev = 2, peaks = 5 } = CONFIG.soup;
459
477
  //event model
460
- const event = {
478
+ const eventTemplate = {
461
479
  event: chosenEvent.event,
462
- $source: "AKsTimeSoup",
480
+ source: "dm4",
463
481
  };
464
482
 
465
483
  //event time
466
- if (isFirstEvent) event.time = dayjs.unix(earliestTime).toISOString();
467
- if (!isFirstEvent) event.time = AKsTimeSoup(earliestTime, NOW);
484
+ if (earliestTime > NOW) {
485
+ earliestTime = dayjs.unix(NOW).subtract(2, 'd').unix();
486
+ };
487
+
488
+ if (isFirstEvent) eventTemplate.time = dayjs.unix(earliestTime).toISOString();
489
+ if (!isFirstEvent) eventTemplate.time = u.TimeSoup(earliestTime, NOW, peaks, dev, mean);
468
490
 
469
491
  // anonymous and session ids
470
- if (global.MP_SIMULATION_CONFIG?.anonIds) event.$device_id = chance.pickone(anonymousIds);
471
- if (global.MP_SIMULATION_CONFIG?.sessionIds) event.$session_id = chance.pickone(sessionIds);
492
+ if (CONFIG?.anonIds) eventTemplate.device_id = CONFIG.chance.pickone(anonymousIds);
493
+ if (CONFIG?.sessionIds) eventTemplate.session_id = CONFIG.chance.pickone(sessionIds);
472
494
 
473
- //sometimes have a $user_id
474
- if (!isFirstEvent && chance.bool({ likelihood: 42 })) event.$user_id = distinct_id;
495
+ //sometimes have a user_id
496
+ if (!isFirstEvent && CONFIG.chance.bool({ likelihood: 42 })) eventTemplate.user_id = distinct_id;
475
497
 
476
- // ensure that there is a $user_id or $device_id
477
- if (!event.$user_id && !event.$device_id) event.$user_id = distinct_id;
498
+ // ensure that there is a user_id or device_id
499
+ if (!eventTemplate.user_id && !eventTemplate.device_id) eventTemplate.user_id = distinct_id;
478
500
 
479
501
  const props = { ...chosenEvent.properties, ...superProps };
480
502
 
481
503
  //iterate through custom properties
482
504
  for (const key in props) {
483
505
  try {
484
- event[key] = u.choose(props[key]);
506
+ eventTemplate[key] = u.choose(props[key]);
485
507
  } catch (e) {
486
508
  console.error(`error with ${key} in ${chosenEvent.event} event`, e);
487
509
  debugger;
@@ -495,99 +517,220 @@ function makeEvent(distinct_id, anonymousIds, sessionIds, earliestTime, events,
495
517
  const groupEvents = groupPair[2] || [];
496
518
 
497
519
  // empty array for group events means all events
498
- if (!groupEvents.length) event[groupKey] = u.pick(u.weightedRange(1, groupCardinality));
499
- if (groupEvents.includes(event.event)) event[groupKey] = u.pick(u.weightedRange(1, groupCardinality));
520
+ if (!groupEvents.length) eventTemplate[groupKey] = u.pick(u.weightedRange(1, groupCardinality));
521
+ if (groupEvents.includes(eventTemplate.event)) eventTemplate[groupKey] = u.pick(u.weightedRange(1, groupCardinality));
500
522
  }
501
523
 
502
524
  //make $insert_id
503
- event.$insert_id = md5(JSON.stringify(event));
525
+ eventTemplate.insert_id = md5(JSON.stringify(eventTemplate));
504
526
 
505
- return event;
527
+ return eventTemplate;
506
528
  }
507
529
 
508
- function buildFileNames(config) {
509
- const { format = "csv", groupKeys = [], lookupTables = [], m } = config;
510
- let extension = "";
511
- extension = format === "csv" ? "csv" : "json";
512
- // const current = dayjs.utc().format("MM-DD-HH");
513
- const simName = config.simulationName;
514
- let writeDir = "./";
515
- if (config.writeToDisk) writeDir = mkdir("./data");
516
- if (typeof writeDir !== "string") throw new Error("writeDir must be a string");
517
- if (typeof simName !== "string") throw new Error("simName must be a string");
518
-
519
- const writePaths = {
520
- eventFiles: [path.join(writeDir, `${simName}-EVENTS.${extension}`)],
521
- userFiles: [path.join(writeDir, `${simName}-USERS.${extension}`)],
522
- scdFiles: [],
523
- mirrorFiles: [],
524
- groupFiles: [],
525
- lookupFiles: [],
526
- folder: writeDir,
527
- };
528
-
529
- //add SCD files
530
- const scdKeys = Object.keys(config?.scdProps || {});
531
- for (const key of scdKeys) {
532
- writePaths.scdFiles.push(
533
- path.join(writeDir, `${simName}-${key}-SCD.${extension}`)
534
- );
530
+ /**
531
+ * creates a funnel of events for a user
532
+ * this is called multiple times for a user
533
+ * @param {Funnel} funnel
534
+ * @param {Person} user
535
+ * @param {UserProfile} profile
536
+ * @param {Record<string, SCDTableRow[]>} scd
537
+ * @param {number} firstEventTime
538
+ * @param {Config} config
539
+ * @return {[EventSpec[], Boolean]}
540
+ */
541
+ function makeFunnel(funnel, user, profile, scd, firstEventTime, config) {
542
+ const { hook } = config;
543
+ hook(funnel, "funnel-pre", { user, profile, scd, funnel, config });
544
+ const { sequence, conversionRate = 50, order = 'sequential', timeToConvert = 1, props } = funnel;
545
+ const { distinct_id, created, anonymousIds, sessionIds } = user;
546
+ const { superProps, groupKeys } = config;
547
+ const { name, email } = profile;
548
+
549
+ const chosenFunnelProps = { ...props, ...superProps };
550
+ for (const key in props) {
551
+ try {
552
+ chosenFunnelProps[key] = u.choose(chosenFunnelProps[key]);
553
+ } catch (e) {
554
+ console.error(`error with ${key} in ${funnel.sequence.join(" > ")} funnel`, e);
555
+ debugger;
556
+ }
535
557
  }
536
558
 
537
- //add group files
538
- for (const groupPair of groupKeys) {
539
- const groupKey = groupPair[0];
559
+ const funnelPossibleEvents = sequence.map((event) => {
560
+ const foundEvent = config.events.find((e) => e.event === event);
561
+ /** @type {EventConfig} */
562
+ const eventSpec = foundEvent || { event, properties: {} };
563
+ for (const key in eventSpec.properties) {
564
+ try {
565
+ eventSpec.properties[key] = u.choose(eventSpec.properties[key]);
566
+ } catch (e) {
567
+ console.error(`error with ${key} in ${eventSpec.event} event`, e);
568
+ debugger;
569
+ }
570
+ }
571
+ delete eventSpec.isFirstEvent;
572
+ delete eventSpec.weight;
573
+ eventSpec.properties = { ...eventSpec.properties, ...chosenFunnelProps };
574
+ return eventSpec;
575
+ });
540
576
 
541
- writePaths.groupFiles.push(
542
- path.join(writeDir, `${simName}-${groupKey}-GROUP.${extension}`)
543
- );
544
- }
577
+ const doesUserConvert = config.chance.bool({ likelihood: conversionRate });
578
+ let numStepsUserWillTake = sequence.length;
579
+ if (!doesUserConvert) numStepsUserWillTake = u.integer(1, sequence.length - 1);
580
+ const funnelTotalRelativeTimeInHours = timeToConvert / numStepsUserWillTake;
581
+ const msInHour = 60000 * 60;
582
+
583
+ let lastTimeJump = 0;
584
+ const funnelActualEvents = funnelPossibleEvents.slice(0, numStepsUserWillTake)
585
+ .map((event, index) => {
586
+ if (index === 0) {
587
+ event.relativeTimeMs = 0;
588
+ return event;
589
+ }
545
590
 
546
- //add lookup files
547
- for (const lookupTable of lookupTables) {
548
- const { key } = lookupTable;
549
- writePaths.lookupFiles.push(
550
- //lookups are always CSVs
551
- path.join(writeDir, `${simName}-${key}-LOOKUP.csv`)
552
- );
553
- }
591
+ // Calculate base increment for each step
592
+ const baseIncrement = (timeToConvert * msInHour) / numStepsUserWillTake;
554
593
 
555
- //add mirror files
556
- const mirrorProps = config?.mirrorProps || {};
557
- if (Object.keys(mirrorProps).length) {
558
- writePaths.mirrorFiles.push(
559
- path.join(writeDir, `${simName}-MIRROR.${extension}`)
560
- );
594
+ // Introduce a random fluctuation factor
595
+ const fluctuation = u.integer(-baseIncrement / u.integer(3, 5), baseIncrement / u.integer(3, 5));
596
+
597
+ // Ensure the time increments are increasing and add randomness
598
+ const previousTime = lastTimeJump;
599
+ const currentTime = previousTime + baseIncrement + fluctuation;
600
+
601
+ // Assign the calculated time to the event
602
+ const chosenTime = Math.max(currentTime, previousTime + 1); // Ensure non-decreasing time
603
+ lastTimeJump = chosenTime;
604
+ event.relativeTimeMs = chosenTime;
605
+ return event;
606
+ });
607
+
608
+
609
+ let funnelActualOrder = [];
610
+
611
+ //todo
612
+ switch (order) {
613
+ case "sequential":
614
+ funnelActualOrder = funnelActualEvents;
615
+ break;
616
+ case "random":
617
+ funnelActualOrder = u.shuffleArray(funnelActualEvents);
618
+ break;
619
+ case "first-fixed":
620
+ funnelActualOrder = u.shuffleExceptFirst(funnelActualEvents);
621
+ break;
622
+ case "last-fixed":
623
+ funnelActualOrder = u.shuffleExceptLast(funnelActualEvents);
624
+ break;
625
+ case "first-and-last-fixed":
626
+ funnelActualOrder = u.fixFirstAndLast(funnelActualEvents);
627
+ break;
628
+ case "middle-fixed":
629
+ funnelActualOrder = u.shuffleOutside(funnelActualEvents);
630
+ break;
631
+ default:
632
+ funnelActualOrder = funnelActualEvents;
633
+ break;
561
634
  }
562
635
 
563
- return writePaths;
636
+ const earliestTime = firstEventTime || dayjs(created).unix();
637
+ let funnelStartTime;
638
+ let finalEvents = funnelActualOrder
639
+ .map((event, index) => {
640
+ const newEvent = makeEvent(distinct_id, anonymousIds, sessionIds, earliestTime, event, {}, groupKeys);
641
+ if (index === 0) {
642
+ funnelStartTime = dayjs(newEvent.time);
643
+ delete newEvent.relativeTimeMs;
644
+ return newEvent;
645
+ }
646
+ newEvent.time = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds").toISOString();
647
+ delete newEvent.relativeTimeMs;
648
+ return newEvent;
649
+ });
650
+
651
+
652
+ hook(finalEvents, "funnel-post", { user, profile, scd, funnel, config });
653
+ return [finalEvents, doesUserConvert];
564
654
  }
565
655
 
566
- /** @typedef {import('./types').EnrichedArray} EnrichArray */
567
- /** @typedef {import('./types').EnrichArrayOptions} EnrichArrayOptions */
568
656
 
569
- /**
570
- * @param {any[]} arr
571
- * @param {EnrichArrayOptions} opts
572
- * @returns {EnrichArray}}
657
+ function makeProfile(props, defaults) {
658
+ //build the spec
659
+ const profile = {
660
+ ...defaults,
661
+ };
662
+
663
+ // anonymous and session ids
664
+ if (!CONFIG?.anonIds) delete profile.anonymousIds;
665
+ if (!CONFIG?.sessionIds) delete profile.sessionIds;
666
+
667
+ for (const key in props) {
668
+ try {
669
+ profile[key] = u.choose(props[key]);
670
+ } catch (e) {
671
+ // debugger;
672
+ }
673
+ }
674
+
675
+ return profile;
676
+ }
677
+
678
+ /**
679
+ * @param {import('./types.d.ts').ValueValid} prop
680
+ * @param {string} scdKey
681
+ * @param {string} distinct_id
682
+ * @param {number} mutations
683
+ * @param {string} created
573
684
  */
574
- function enrichArray(arr = [], opts = {}) {
575
- const { hook = a => a, type = "", ...rest } = opts;
685
+ function makeSCD(prop, scdKey, distinct_id, mutations, created) {
686
+ if (JSON.stringify(prop) === "{}") return {};
687
+ if (JSON.stringify(prop) === "[]") return [];
688
+ const scdEntries = [];
689
+ let lastInserted = dayjs(created);
690
+ const deltaDays = dayjs().diff(lastInserted, "day");
576
691
 
577
- function transformThenPush(item) {
578
- return arr.push(hook(item, type, rest));
692
+ for (let i = 0; i < mutations; i++) {
693
+ if (lastInserted.isAfter(dayjs())) break;
694
+ const scd = makeProfile({ [scdKey]: prop }, { distinct_id });
695
+ scd.startTime = lastInserted.toISOString();
696
+ lastInserted = lastInserted.add(u.integer(1, 1000), "seconds");
697
+ scd.insertTime = lastInserted.toISOString();
698
+ scdEntries.push({ ...scd });
699
+ lastInserted = lastInserted
700
+ .add(u.integer(0, deltaDays), "day")
701
+ .subtract(u.integer(1, 1000), "seconds");
579
702
  }
580
703
 
581
- /** @type {EnrichArray} */
582
- // @ts-ignore
583
- const enrichedArray = arr;
704
+ return scdEntries;
705
+ }
706
+
707
+ /**
708
+ * @param {EventConfig[] | string[]} events
709
+ */
710
+ function validateEvents(events) {
711
+ if (!Array.isArray(events)) throw new Error("events must be an array");
712
+ const cleanEventConfig = [];
713
+ for (const event of events) {
714
+ if (typeof event === "string") {
715
+ /** @type {EventConfig} */
716
+ const eventTemplate = {
717
+ event,
718
+ isFirstEvent: false,
719
+ properties: {},
720
+ weight: u.integer(1, 5)
721
+ };
722
+ cleanEventConfig.push(eventTemplate);
723
+ }
724
+ if (typeof event === "object") {
725
+ cleanEventConfig.push(event);
726
+ }
727
+ }
728
+ return cleanEventConfig;
729
+ }
730
+
584
731
 
585
732
 
586
- enrichedArray.hPush = transformThenPush;
587
-
588
733
 
589
- return enrichedArray;
590
- };
591
734
 
592
735
 
593
736
 
@@ -596,12 +739,11 @@ if (require.main === module) {
596
739
  isCLI = true;
597
740
  const args = cliParams();
598
741
  // @ts-ignore
599
- const { token, seed, format, numDays, numUsers, numEvents, region, writeToDisk, complex = false, sessionIds, anonIds } = args;
742
+ let { token, seed, format, numDays, numUsers, numEvents, region, writeToDisk, complex = false, sessionIds, anonIds } = args;
600
743
  // @ts-ignore
601
744
  const suppliedConfig = args._[0];
602
745
 
603
- //if the user specifics an separate config file
604
- //todo this text isn't displaying
746
+ //if the user specifies an separate config file
605
747
  let config = null;
606
748
  if (suppliedConfig) {
607
749
  console.log(`using ${suppliedConfig} for data\n`);
@@ -612,18 +754,19 @@ if (require.main === module) {
612
754
  console.log(`... using default COMPLEX configuration [everything] ...\n`);
613
755
  console.log(`... for more simple data, don't use the --complex flag ...\n`);
614
756
  console.log(`... or specify your own js config file (see docs or --help) ...\n`);
615
- config = require(path.resolve(__dirname, "./models/complex.js"));
757
+ config = require(path.resolve(__dirname, "./schemas/complex.js"));
616
758
  }
617
759
  else {
618
760
  console.log(`... using default SIMPLE configuration [events + users] ...\n`);
619
761
  console.log(`... for more complex data, use the --complex flag ...\n`);
620
- config = require(path.resolve(__dirname, "./models/simple.js"));
762
+ config = require(path.resolve(__dirname, "./schemas/simple.js"));
621
763
  }
622
764
  }
623
765
 
624
766
  //override config with cli params
625
767
  if (token) config.token = token;
626
768
  if (seed) config.seed = seed;
769
+ if (format === "csv" && config.format === "json") format = "json";
627
770
  if (format) config.format = format;
628
771
  if (numDays) config.numDays = numDays;
629
772
  if (numUsers) config.numUsers = numUsers;
@@ -640,7 +783,7 @@ if (require.main === module) {
640
783
  log(`-----------------SUMMARY-----------------`);
641
784
  const d = { success: 0, bytes: 0 };
642
785
  const darr = [d];
643
- const { events = d, groups = darr, users = d } = data.import;
786
+ const { events = d, groups = darr, users = d } = data.importResults;
644
787
  const files = data.files;
645
788
  const folder = files?.pop();
646
789
  const groupBytes = groups.reduce((acc, group) => {
@@ -673,7 +816,6 @@ if (require.main === module) {
673
816
  });
674
817
  } else {
675
818
  main.utils = { ...u };
676
- main.timeSoup = AKsTimeSoup;
677
819
  module.exports = main;
678
820
  }
679
821