make-mp-data 3.0.5 → 3.1.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/README.md CHANGED
@@ -13,6 +13,30 @@ under the hood, `make-mp-data` is modeling data adherent to match [Mixpanel's da
13
13
 
14
14
  ## 🚀 Quick Start
15
15
 
16
+ ### Canonical Usage (v3.1.0+)
17
+
18
+ Two paths are guaranteed to "just work":
19
+
20
+ **1. As a CLI — send data to Mixpanel with one flag:**
21
+
22
+ ```bash
23
+ npx make-mp-data --token YOUR_PROJECT_TOKEN
24
+ ```
25
+
26
+ **2. As an ES module — bare `await` call:**
27
+
28
+ ```javascript
29
+ import makeMpData from 'make-mp-data';
30
+
31
+ // Zero-config: generates events + users, returns them in memory
32
+ const result = await makeMpData({});
33
+
34
+ // Canonical "just send it": pass a token, data ships to Mixpanel
35
+ await makeMpData({ token: 'YOUR_PROJECT_TOKEN' });
36
+ ```
37
+
38
+ Both paths return a `Result` object with `eventData`, `userProfilesData`, `importResults`, `files`, `eventCount`, `userCount`, and timing info.
39
+
16
40
  ### Basic Usage
17
41
 
18
42
  Generate events and users, and write them to CSV files:
@@ -87,6 +111,16 @@ Here's a breakdown of the CLI options you can use with `make-mp-data`:
87
111
  - `--complex`: create a complex set models including groups, SCD, and lookup tables.
88
112
  - `--simple`: create a simple dataset including events, and users
89
113
 
114
+ ### Custom Dungeon Configs
115
+
116
+ Pass a path to your own dungeon `.js` file. CLI flags override values from the dungeon — your file provides defaults, the CLI tunes them:
117
+
118
+ ```bash
119
+ npx make-mp-data ./my-dungeon.js --numUsers 500 --token YOUR_TOKEN
120
+ ```
121
+
122
+ CLI flag defaults (like `region`, `concurrency`) **do not** clobber explicit values in your dungeon — only flags you actually pass take effect.
123
+
90
124
  ## ⏱️ TimeSoup — Realistic Time Distributions
91
125
 
92
126
  TimeSoup controls how events are distributed across time. Out of the box, it produces realistic day-of-week and hour-of-day patterns derived from real Mixpanel data (weekday-heavy, Saturday valley, morning peak).
@@ -0,0 +1,428 @@
1
+ /**
2
+ * This is the default configuration file for the data generator in COMPLEX mode
3
+ * notice how the config object is structured, and see it's type definition in ./types.d.ts
4
+ * feel free to modify this file to customize the data you generate
5
+ * see helper functions in utils.js for more ways to generate data
6
+ */
7
+
8
+
9
+ import Chance from 'chance';
10
+ const chance = new Chance();
11
+ import dayjs from "dayjs";
12
+ import utc from "dayjs/plugin/utc.js";
13
+ dayjs.extend(utc);
14
+ import { weighNumRange, date, integer } from "../lib/utils/utils.js";
15
+ import * as u from 'ak-tools';
16
+
17
+ /** @type {import('../types.js').Dungeon} */
18
+ const config = {
19
+ token: "",
20
+ seed: "quite complexus",
21
+ numDays: 30, //how many days worth of data
22
+ numEvents: 100_000, //how many events
23
+ numUsers: 1000, //how many users
24
+ format: 'json', //csv or json
25
+ region: "US",
26
+ hasAnonIds: true, //if true, anonymousIds are created for each user
27
+ hasSessionIds: true, //if true, hasSessionIds are created for each user
28
+
29
+ hasLocation: true,
30
+ hasAndroidDevices: true,
31
+ hasIOSDevices: true,
32
+ hasDesktopDevices: true,
33
+ hasBrowser: true,
34
+ hasCampaigns: true,
35
+ isAnonymous: false,
36
+ hasAdSpend: true,
37
+
38
+ hasAvatar: true,
39
+
40
+ batchSize: 500_000,
41
+ concurrency: 1,
42
+
43
+ funnels: [],
44
+ events: [
45
+ {
46
+ "event": "checkout",
47
+ "weight": 2,
48
+ "properties": {
49
+ amount: weighNumRange(5, 500, .25),
50
+ currency: ["USD", "USD", "USD", "CAD", "EUR", "EUR", "BTC", "BTC", "ETH", "JPY"],
51
+ cart: makeProducts(12),
52
+ }
53
+ },
54
+ {
55
+ "event": "add to cart",
56
+ "weight": 4,
57
+ "properties": {
58
+ amount: weighNumRange(5, 500, .25),
59
+ qty: integer(1, 5),
60
+ product_id: weighNumRange(1, 1000, 1.4)
61
+ }
62
+ },
63
+ {
64
+ "event": "page view",
65
+ "weight": 10,
66
+ "properties": {
67
+ page: ["/", "/", "/", "/learn-more", "/pricing", "/contact", "/about", "/careers", "/sign-up", "/login", "/app", "/app", "/app", "/app"],
68
+ utm_source: ["$organic", "$organic", "$organic", "$organic", "google", "google", "google", "facebook", "facebook", "twitter", "linkedin"],
69
+ }
70
+ },
71
+ {
72
+ "event": "watch video",
73
+ "weight": 8,
74
+ "properties": {
75
+ category: ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"],
76
+ hashTags: makeHashTags,
77
+ watchTimeSec: weighNumRange(10, 600, .25,),
78
+ quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
79
+ format: ["mp4", "avi", "mov", "mpg"],
80
+ video_id: weighNumRange(1, 50000, 1.4),
81
+
82
+ }
83
+ },
84
+ {
85
+ "event": "comment",
86
+ "weight": 2,
87
+ "properties": {
88
+ length: weighNumRange(1, 500, .25),
89
+ video_id: weighNumRange(1, 50000, 1.4),
90
+ has_replies: [true, false, false, false, false],
91
+ has_photo: [true, false, false, false, false],
92
+
93
+ }
94
+ },
95
+ {
96
+ "event": "save video",
97
+ "weight": 4,
98
+ "properties": {
99
+ video_id: weighNumRange(1, 50000, 1.4),
100
+ ui_control: ["toolbar", "menu", "keyboard"]
101
+
102
+
103
+ }
104
+ },
105
+ {
106
+ "event": "view item",
107
+ "weight": 8,
108
+ "properties": {
109
+ product_id: weighNumRange(1, 24, 3),
110
+ colors: ["light", "dark", "custom", "dark"]
111
+ }
112
+ },
113
+ {
114
+ "event": "save item",
115
+ "weight": 5,
116
+ "properties": {
117
+ product_id: weighNumRange(1, 1000, 12),
118
+ colors: ["light", "dark", "custom", "dark"]
119
+ }
120
+ },
121
+ {
122
+ "event": "support ticket",
123
+ "weight": 2,
124
+ "properties": {
125
+ product_id: weighNumRange(1, 1000, .6),
126
+ description: chance.sentence.bind(chance),
127
+ severity: ["low", "medium", "high"],
128
+ ticket_id: chance.guid.bind(chance)
129
+ }
130
+ },
131
+ {
132
+ "event": "sign up",
133
+ "isFirstEvent": true,
134
+ "weight": 0,
135
+ "properties": {
136
+ plan: ["free", "free", "free", "free", "basic", "basic", "basic", "premium", "premium", "enterprise"],
137
+ dateOfRenewal: date(100, false),
138
+ codewords: u.makeName,
139
+ }
140
+ }
141
+ ],
142
+ superProps: {
143
+ linked_device: deviceAttributes()
144
+ // emotions: generateEmoji(),
145
+
146
+ },
147
+ /*
148
+ user properties work the same as event properties
149
+ each key should be an array or function reference
150
+ */
151
+ userProps: {
152
+ title: chance.profession.bind(chance),
153
+ luckyNumber: weighNumRange(42, 420),
154
+ experiment: designExperiment(),
155
+ spiritAnimal: ["unicorn", "dragon", "phoenix", "sasquatch", "yeti", "kraken", "jackalope", "thunderbird", "mothman", "nessie", "chupacabra", "jersey devil", "bigfoot", "weindgo", "bunyip", "mokele-mbembe", "tatzelwurm", "megalodon"],
156
+ timezone: chance.timezone.bind(chance), // ["America/New_York", "America/Los_Angeles", "America/Chicago", "America/Denver", "America/Phoenix", "America/Anchorage", "Pacific/Honolulu"]
157
+ ip: chance.ip.bind(chance),
158
+ lastCart: makeProducts(5),
159
+
160
+ },
161
+
162
+ /** each generates it's own table */
163
+ scdProps: {
164
+ role: {
165
+ type: "user",
166
+ frequency: "week",
167
+ values: ["admin", "collaborator", "user", "view only", "no access"],
168
+ timing: 'fuzzy',
169
+ max: 10
170
+ },
171
+ NPS: {
172
+ type: "user",
173
+ frequency: "day",
174
+ values: weighNumRange(1, 10, 2, 150),
175
+ timing: 'fuzzy',
176
+ max: 10
177
+ },
178
+ MRR: {
179
+ type: "company_id",
180
+ frequency: "month",
181
+ values: weighNumRange(0, 10000, .15),
182
+ timing: 'fixed',
183
+ max: 10
184
+ },
185
+ AccountHealthScore: {
186
+ type: "company_id",
187
+ frequency: "week",
188
+ values: weighNumRange(1, 10, .15),
189
+ timing: 'fixed',
190
+ max: 40
191
+ },
192
+ plan: {
193
+ type: "company_id",
194
+ frequency: "month",
195
+ values: ["free", "basic", "premium", "enterprise"],
196
+ timing: 'fixed',
197
+ max: 10
198
+ }
199
+ },
200
+
201
+ mirrorProps: {
202
+ isBot: { events: "*", values: [false, false, false, false, true] },
203
+ profit: { events: ["checkout"], values: [4, 2, 42, 420] },
204
+ watchTimeSec: {
205
+ events: ["watch video"],
206
+ values: weighNumRange(50, 1200, 2)
207
+ }
208
+ },
209
+
210
+ /*
211
+ for group analytics keys, we need an array of arrays [[],[],[]]
212
+ each pair represents a group_key and the number of profiles for that key
213
+ */
214
+ groupKeys: [
215
+ ['company_id', 500, []],
216
+ ['room_id', 10000, ["save video", "comment", "watch video"]],
217
+
218
+ ],
219
+ groupProps: {
220
+ company_id: {
221
+ name: () => { return chance.company(); },
222
+ email: () => { return `CSM: ${chance.pickone(["AK", "Jessica", "Michelle", "Dana", "Brian", "Dave"])}`; },
223
+ "# of employees": weighNumRange(3, 10000),
224
+ "industry": ["tech", "finance", "healthcare", "education", "government", "non-profit"],
225
+ "segment": ["enterprise", "SMB", "mid-market"],
226
+ "products": [["core"], ["core"], ["core", "add-ons"], ["core", "pro-serve"], ["core", "add-ons", "pro-serve"], ["core", "BAA", "enterprise"], ["free"], ["free"], ["free", "addons"]],
227
+ },
228
+ room_id: {
229
+ name: () => { return `#${chance.word({ length: integer(4, 24), capitalize: true })}`; },
230
+ email: ["public", "private"],
231
+ "room provider": ["partner", "core", "core", "core"],
232
+ "room capacity": weighNumRange(3, 1000000),
233
+ "isPublic": [true, false, false, false, false],
234
+ "country": chance.country.bind(chance),
235
+ "isVerified": [true, true, false, false, false],
236
+ }
237
+ },
238
+ groupEvents: [{
239
+ attribute_to_user: false,
240
+ event: "card charged",
241
+ weight: 1,
242
+ frequency: 30,
243
+ group_key: "company_id",
244
+ group_size: 500,
245
+ properties: {
246
+ amount: weighNumRange(5, 500, .25),
247
+ currency: ["USD", "USD", "USD", "CAD", "EUR", "EUR", "BTC", "BTC", "ETH", "JPY"],
248
+ plan: ["basic", "premium", "enterprise"],
249
+ "payment method": []
250
+ }
251
+ }],
252
+
253
+ lookupTables: [
254
+ {
255
+ key: "product_id",
256
+ entries: 1000,
257
+ attributes: {
258
+ category: [
259
+ "Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden & Tools", "Pet Supplies", "Food & Grocery", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art"
260
+ ],
261
+ "demand": ["high", "medium", "medium", "low"],
262
+ "supply": ["high", "medium", "medium", "low"],
263
+ "manufacturer": chance.company.bind(chance),
264
+ "price": weighNumRange(5, 500, .25),
265
+ "rating": weighNumRange(1, 5),
266
+ "reviews": weighNumRange(0, 35)
267
+ }
268
+
269
+ },
270
+ {
271
+ key: "video_id",
272
+ entries: 50000,
273
+ attributes: {
274
+ isFlagged: [true, false, false, false, false],
275
+ copyright: ["all rights reserved", "creative commons", "creative commons", "public domain", "fair use"],
276
+ uploader_id: chance.guid.bind(chance),
277
+ "uploader influence": ["low", "low", "low", "medium", "medium", "high"],
278
+ thumbs: weighNumRange(0, 35),
279
+ rating: ["G", "PG", "PG-13", "R", "NC-17", "PG-13", "R", "NC-17", "R", "PG", "PG"]
280
+ }
281
+
282
+ }
283
+ ],
284
+
285
+ hook: function (record, type, meta) {
286
+ // event hook: weekend watch time boost — videos watched on weekends get 1.5x duration
287
+ if (type === "event") {
288
+ if (record.event === "watch video" && record.time) {
289
+ const day = dayjs(record.time).day();
290
+ if (day === 0 || day === 6) {
291
+ record.watchTimeSec = Math.round((record.watchTimeSec || 60) * 1.5);
292
+ record.is_weekend = true;
293
+ }
294
+ }
295
+ // support tickets on high-severity get escalated flag
296
+ if (record.event === "support ticket" && record.severity === "high") {
297
+ record.escalated = true;
298
+ }
299
+ }
300
+
301
+ // everything hook: simulate cart abandonment — users who "add to cart" but never "checkout" lose their last add-to-cart
302
+ if (type === "everything") {
303
+ const hasCheckout = record.some(e => e.event === "checkout");
304
+ const hasAddToCart = record.some(e => e.event === "add to cart");
305
+ if (hasAddToCart && !hasCheckout) {
306
+ // mark all their add-to-cart events as abandoned
307
+ for (const e of record) {
308
+ if (e.event === "add to cart") {
309
+ e.abandoned = true;
310
+ }
311
+ }
312
+ }
313
+ return record;
314
+ }
315
+
316
+ return record;
317
+ }
318
+ };
319
+
320
+
321
+
322
+ function makeHashTags() {
323
+ const possibleHashtags = [];
324
+ for (let i = 0; i < 20; i++) {
325
+ possibleHashtags.push('#' + u.makeName(2, ''));
326
+ }
327
+
328
+ const numHashtags = integer(integer(1, 5), integer(5, 10));
329
+ const hashtags = [];
330
+ for (let i = 0; i < numHashtags; i++) {
331
+ hashtags.push(chance.pickone(possibleHashtags));
332
+ }
333
+ return [hashtags];
334
+ };
335
+
336
+ function makeProducts(maxItems = 10) {
337
+
338
+ return function () {
339
+ const 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"];
340
+ const slugs = ['/sale/', '/featured/', '/home/', '/search/', '/wishlist/', '/'];
341
+ const assetExtension = ['.png', '.jpg', '.jpeg', '.heic', '.mp4', '.mov', '.avi'];
342
+ const data = [];
343
+ const numOfItems = integer(1, 12);
344
+
345
+ for (var i = 0; i < numOfItems; i++) {
346
+ const category = chance.pickone(categories);
347
+ const slug = chance.pickone(slugs);
348
+ const asset = chance.pickone(assetExtension);
349
+ const product_id = chance.guid();
350
+ const price = integer(1, 300);
351
+ const quantity = integer(1, 5);
352
+
353
+ const item = {
354
+ product_id: product_id,
355
+ sku: integer(11111, 99999),
356
+ amount: price,
357
+ quantity: quantity,
358
+ value: price * quantity,
359
+ featured: chance.pickone([true, false]),
360
+ category: category,
361
+ urlSlug: slug + category,
362
+ asset: `${category}-${integer(1, 20)}${asset}`
363
+ };
364
+
365
+ data.push(item);
366
+ }
367
+
368
+ return () => [data];
369
+ };
370
+ };
371
+
372
+
373
+ function designExperiment() {
374
+ return function () {
375
+ const variants = ["A", "B", "C", "Control"];
376
+ const variant = chance.pickone(variants);
377
+ const experiments = ["no password", "social sign in", "new tutorial", "new search"];
378
+ const experiment = chance.pickone(experiments);
379
+ const multi_variates = ["A/B", "A/B/C", "A/B/C/D", "Control"];
380
+ const multi_variate = chance.pickone(multi_variates);
381
+ const impression_id = chance.guid();
382
+
383
+
384
+
385
+ const chosen = {
386
+ variant,
387
+ experiment,
388
+ multi_variate,
389
+ impression_id
390
+ };
391
+
392
+ return [chosen];
393
+ };
394
+ }
395
+
396
+ function deviceAttributes(isMobile = false) {
397
+ return function () {
398
+ let devices = ["desktop", "laptop", "desktop", "laptop", "desktop", "laptop", "other"];
399
+ if (isMobile) devices = [...devices, "mobile", "mobile", "mobile", "tablet"];
400
+ const device = chance.pickone(devices);
401
+ let oses = ["Windows", "macOS", "Windows", "macOS", "macOS", "Linux", "Windows", "macOS", "Windows", "macOS", "macOS", "TempleOS"];
402
+ if (isMobile) oses = [...oses, "iOS", "Android", "iOS", "Android"];
403
+ const os = chance.pickone(oses);
404
+ const browser = chance.pickone(["Chrome", "Firefox", "Safari", "Edge", "Opera", "IE", "Brave", "Vivaldi"]);
405
+ const version = chance.integer({ min: 1, max: 15 });
406
+ const resolution = chance.pickone(["1920x1080", "1280x720", "1024x768", "800x600", "640x480"]);
407
+ const language = chance.pickone(["en-US", "en-US", "en-US", "en-GB", "es", "es", "fr", "de", "it", "ja", "zh", "ru"]);
408
+
409
+ const chosen = {
410
+ platform: device,
411
+ os,
412
+ browser,
413
+ version,
414
+ resolution,
415
+ language
416
+ };
417
+
418
+ return chosen;
419
+
420
+ };
421
+ }
422
+
423
+
424
+
425
+
426
+
427
+
428
+ export default config;
package/entry.js CHANGED
@@ -59,7 +59,7 @@ import getCliParams from './lib/cli/cli.js';
59
59
  process.exit(0);
60
60
  } catch (error) {
61
61
  console.error(`\n❌ Job failed: ${error.message}`);
62
- if (process.env.NODE_ENV === 'development') {
62
+ if (process.env.NODE_ENV !== 'production') {
63
63
  console.error(error.stack);
64
64
  }
65
65
  process.exit(1);
package/index.js CHANGED
@@ -34,6 +34,8 @@ import dayjs from "dayjs";
34
34
  import utc from "dayjs/plugin/utc.js";
35
35
  import { timer } from 'ak-tools';
36
36
  import { existsSync } from 'fs';
37
+ import path from 'path';
38
+ import { pathToFileURL } from 'url';
37
39
  import { dataLogger as logger } from './lib/utils/logger.js';
38
40
 
39
41
  // Initialize dayjs and time constants
@@ -123,8 +125,11 @@ async function main(config) {
123
125
  console.log(`\n🔍 Loading dungeon config from: ${firstArg}`);
124
126
  }
125
127
  try {
126
- const dungeonConfig = await import(firstArg);
127
- config = dungeonConfig.default || dungeonConfig;
128
+ const absolutePath = path.resolve(firstArg);
129
+ const dungeonConfig = await import(pathToFileURL(absolutePath).href);
130
+ const loaded = dungeonConfig.default || dungeonConfig;
131
+ // Merge: dungeon config provides defaults; CLI flags override
132
+ config = { ...loaded, ...config };
128
133
  } catch (error) {
129
134
  console.error(`\n❌ Error loading dungeon config from ${firstArg}: ${error.message}`);
130
135
  throw error;
package/lib/cli/cli.js CHANGED
@@ -93,21 +93,18 @@ DATA MODEL: https://github.com/ak--47/make-mp-data/blob/main/default.js
93
93
  })
94
94
  .option("region", {
95
95
  demandOption: false,
96
- default: 'US',
97
96
  alias: 'r',
98
- describe: 'either US or EU or IN',
97
+ describe: 'either US or EU or IN (defaults to dungeon config or US)',
99
98
  type: 'string'
100
99
  })
101
100
  .option('concurrency', {
102
101
  alias: 'conn',
103
- default: 10,
104
102
  demandOption: false,
105
- describe: 'concurrency level for data generation',
103
+ describe: 'concurrency level for data generation (defaults to dungeon config)',
106
104
  type: 'number'
107
105
  })
108
106
  .options("complex", {
109
107
  demandOption: false,
110
- default: false,
111
108
  describe: 'use complex data model (model all entities)',
112
109
  alias: 'c',
113
110
  type: 'boolean',
@@ -115,7 +112,6 @@ DATA MODEL: https://github.com/ak--47/make-mp-data/blob/main/default.js
115
112
  })
116
113
  .options("simple", {
117
114
  demandOption: false,
118
- default: false,
119
115
  describe: 'use simple data model (basic events and users)',
120
116
  alias: 'simp',
121
117
  type: 'boolean',
@@ -123,7 +119,6 @@ DATA MODEL: https://github.com/ak--47/make-mp-data/blob/main/default.js
123
119
  })
124
120
  .options("sanity", {
125
121
  demandOption: false,
126
- default: false,
127
122
  describe: 'run sanity checks on the generated data',
128
123
  alias: 'san',
129
124
  type: 'boolean',
@@ -131,15 +126,13 @@ DATA MODEL: https://github.com/ak--47/make-mp-data/blob/main/default.js
131
126
  })
132
127
  .option("writeToDisk", {
133
128
  demandOption: false,
134
- default: true,
135
- describe: 'write data to disk',
129
+ describe: 'write data to disk (CLI defaults to true)',
136
130
  alias: 'w',
137
131
  type: 'boolean',
138
132
  coerce: boolCoerce
139
133
  })
140
134
  .option("hasSessionIds", {
141
135
  demandOption: false,
142
- default: false,
143
136
  describe: 'create session ids in the data',
144
137
  alias: 'sid',
145
138
  type: 'boolean',
@@ -147,7 +140,6 @@ DATA MODEL: https://github.com/ak--47/make-mp-data/blob/main/default.js
147
140
  })
148
141
  .option("hasAnonIds", {
149
142
  demandOption: false,
150
- default: false,
151
143
  describe: 'create anonymous ids in the data',
152
144
  alias: 'aid',
153
145
  type: 'boolean',
@@ -341,18 +341,15 @@ function transformSCDPropsWithoutCredentials(config) {
341
341
 
342
342
  // Missing credentials - handle based on job type
343
343
  if (!isUIJob) {
344
- // For programmatic/CLI usage, throw an error if trying to send SCDs to Mixpanel without credentials
345
- if (token) {
346
- throw new Error(
347
- 'Configuration error: SCD properties are configured but service credentials are missing.\n' +
348
- 'To import SCD data to Mixpanel, you must provide:\n' +
349
- ' - serviceAccount: Your Mixpanel service account username\n' +
350
- ' - serviceSecret: Your Mixpanel service account secret\n' +
351
- ' - projectId: Your Mixpanel project ID\n' +
352
- 'Without these credentials, SCD data cannot be imported to Mixpanel.'
344
+ // For programmatic/CLI usage: warn but continue. SCD files still generate;
345
+ // mixpanel-sender already gates SCD import on credential presence.
346
+ if (token && config.verbose !== false) {
347
+ console.warn(
348
+ '⚠️ SCD properties configured but service credentials missing. ' +
349
+ 'SCD files will be generated but NOT imported to Mixpanel. ' +
350
+ 'Provide serviceAccount + serviceSecret + projectId to enable SCD import.'
353
351
  );
354
352
  }
355
- // If not sending to Mixpanel (no token), allow generation for testing
356
353
  return;
357
354
  }
358
355
 
@@ -57,7 +57,8 @@ export async function sendToMixpanel(context) {
57
57
  fixJson: false,
58
58
  showProgress: !!config.verbose,
59
59
  streamFormat: mpImportFormat,
60
- workers: 35
60
+ workers: 35,
61
+ v2_compat: true,
61
62
  };
62
63
 
63
64
  log(`\n${'─'.repeat(50)}`);
@@ -65,8 +66,7 @@ export async function sendToMixpanel(context) {
65
66
  log(`${'─'.repeat(50)}\n`);
66
67
 
67
68
  // Import events
68
- if (eventData?.length > 0 || isBATCH_MODE) {
69
- log(` Events`);
69
+ if (eventData?.length > 0 || isBATCH_MODE || (writeToDisk && eventData)) {
70
70
  let eventDataToImport = u.deepClone(eventData);
71
71
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && eventData && eventData.length === 0);
72
72
  if (shouldReadFromFiles && eventData?.getWriteDir) {
@@ -75,17 +75,19 @@ export async function sendToMixpanel(context) {
75
75
  // @ts-ignore
76
76
  eventDataToImport = files.filter(f => f.includes('-EVENTS'));
77
77
  }
78
- const imported = await mp(creds, eventDataToImport, {
79
- recordType: "event",
80
- ...commonOpts,
81
- });
82
- log(` -> ${comma(imported.success)} events sent\n`);
83
- importResults.events = imported;
78
+ if (eventDataToImport?.length > 0) {
79
+ log(` Events`);
80
+ const imported = await mp(creds, eventDataToImport, {
81
+ recordType: "event",
82
+ ...commonOpts,
83
+ });
84
+ log(` -> ${comma(imported.success)} events sent\n`);
85
+ importResults.events = imported;
86
+ }
84
87
  }
85
88
 
86
89
  // Import user profiles
87
- if (userProfilesData?.length > 0 || isBATCH_MODE) {
88
- log(` User Profiles`);
90
+ if (userProfilesData?.length > 0 || isBATCH_MODE || (writeToDisk && userProfilesData)) {
89
91
  let userProfilesToImport = u.deepClone(userProfilesData);
90
92
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && userProfilesData && userProfilesData.length === 0);
91
93
  if (shouldReadFromFiles && userProfilesData?.getWriteDir) {
@@ -94,17 +96,19 @@ export async function sendToMixpanel(context) {
94
96
  // @ts-ignore
95
97
  userProfilesToImport = files.filter(f => f.includes('-USERS'));
96
98
  }
97
- const imported = await mp(creds, userProfilesToImport, {
98
- recordType: "user",
99
- ...commonOpts,
100
- });
101
- log(` -> ${comma(imported.success)} user profiles sent\n`);
102
- importResults.users = imported;
99
+ if (userProfilesToImport?.length > 0) {
100
+ log(` User Profiles`);
101
+ const imported = await mp(creds, userProfilesToImport, {
102
+ recordType: "user",
103
+ ...commonOpts,
104
+ });
105
+ log(` -> ${comma(imported.success)} user profiles sent\n`);
106
+ importResults.users = imported;
107
+ }
103
108
  }
104
109
 
105
- // Import ad spend data
106
- if (adSpendData?.length > 0 || isBATCH_MODE) {
107
- log(` Ad Spend`);
110
+ // Import ad spend data (only when feature enabled)
111
+ if (config.hasAdSpend && (adSpendData?.length > 0 || isBATCH_MODE || (writeToDisk && adSpendData))) {
108
112
  let adSpendDataToImport = u.deepClone(adSpendData);
109
113
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && adSpendData && adSpendData.length === 0);
110
114
  if (shouldReadFromFiles && adSpendData?.getWriteDir) {
@@ -113,20 +117,23 @@ export async function sendToMixpanel(context) {
113
117
  // @ts-ignore
114
118
  adSpendDataToImport = files.filter(f => f.includes('-ADSPEND'));
115
119
  }
116
- const imported = await mp(creds, adSpendDataToImport, {
117
- recordType: "event",
118
- ...commonOpts,
119
- });
120
- log(` -> ${comma(imported.success)} ad spend events sent\n`);
121
- importResults.adSpend = imported;
120
+ if (adSpendDataToImport?.length > 0) {
121
+ log(` Ad Spend`);
122
+ const imported = await mp(creds, adSpendDataToImport, {
123
+ recordType: "event",
124
+ ...commonOpts,
125
+ });
126
+ log(` -> ${comma(imported.success)} ad spend events sent\n`);
127
+ importResults.adSpend = imported;
128
+ }
122
129
  }
123
130
 
124
131
  // Import group profiles
125
132
  if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
126
133
  for (const groupEntity of groupProfilesData) {
127
- if (!groupEntity || groupEntity.length === 0) continue;
134
+ if (!groupEntity) continue;
135
+ if (groupEntity.length === 0 && !isBATCH_MODE && !writeToDisk) continue;
128
136
  const groupKey = groupEntity?.groupKey;
129
- log(` Group Profiles (${groupKey})`);
130
137
  let groupProfilesToImport = u.deepClone(groupEntity);
131
138
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEntity.length === 0);
132
139
  if (shouldReadFromFiles && groupEntity?.getWriteDir) {
@@ -135,6 +142,8 @@ export async function sendToMixpanel(context) {
135
142
  // @ts-ignore
136
143
  groupProfilesToImport = files.filter(f => f.includes(`-${groupKey}-GROUPS`));
137
144
  }
145
+ if (!groupProfilesToImport?.length) continue;
146
+ log(` Group Profiles (${groupKey})`);
138
147
  const imported = await mp({ token, groupKey }, groupProfilesToImport, {
139
148
  recordType: "group",
140
149
  ...commonOpts,
@@ -146,8 +155,7 @@ export async function sendToMixpanel(context) {
146
155
  }
147
156
 
148
157
  // Import group events
149
- if (groupEventData?.length > 0) {
150
- log(` Group Events`);
158
+ if (groupEventData?.length > 0 || isBATCH_MODE || (writeToDisk && groupEventData)) {
151
159
  let groupEventDataToImport = u.deepClone(groupEventData);
152
160
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEventData.length === 0);
153
161
  if (shouldReadFromFiles && groupEventData?.getWriteDir) {
@@ -156,13 +164,17 @@ export async function sendToMixpanel(context) {
156
164
  // @ts-ignore
157
165
  groupEventDataToImport = files.filter(f => f.includes('-GROUP-EVENTS'));
158
166
  }
159
- const imported = await mp(creds, groupEventDataToImport, {
160
- recordType: "event",
161
- ...commonOpts,
162
- strict: false
163
- });
164
- log(` -> ${comma(imported.success)} group events sent\n`);
165
- importResults.groupEvents = imported;
167
+ // Skip if no data to import (avoids mp() throwing on empty input)
168
+ if (groupEventDataToImport?.length > 0) {
169
+ log(` Group Events`);
170
+ const imported = await mp(creds, groupEventDataToImport, {
171
+ recordType: "event",
172
+ ...commonOpts,
173
+ strict: false
174
+ });
175
+ log(` -> ${comma(imported.success)} group events sent\n`);
176
+ importResults.groupEvents = imported;
177
+ }
166
178
  }
167
179
 
168
180
  // Import SCD data (requires service account)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "make-mp-data",
3
- "version": "3.0.5",
3
+ "version": "3.1.0",
4
4
  "description": "builds all mixpanel primitives for a given project",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -60,7 +60,7 @@
60
60
  "dotenv": "^16.4.5",
61
61
  "hyparquet-writer": "^0.6.1",
62
62
  "mixpanel": "^0.18.0",
63
- "mixpanel-import": "^3.2.8",
63
+ "mixpanel-import": "^3.3.1",
64
64
  "p-limit": "^3.1.0",
65
65
  "pino": "^9.0.0",
66
66
  "pino-pretty": "^11.0.0",