@ak--47/dungeon-master 1.2.0 → 1.2.2

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.
Files changed (54) hide show
  1. package/dungeons/technical/ad-spend.js +15 -4
  2. package/dungeons/technical/anonymous-users.js +15 -5
  3. package/dungeons/technical/array-of-object-lookup.js +21 -5
  4. package/dungeons/technical/experiments.js +23 -5
  5. package/dungeons/technical/foobar.js +28 -12
  6. package/dungeons/technical/group-analytics.js +15 -5
  7. package/dungeons/technical/mirror-strategies.js +15 -5
  8. package/dungeons/technical/nested-objects.js +15 -4
  9. package/dungeons/technical/retention-cadence.js +13 -7
  10. package/dungeons/technical/sanity.js +25 -6
  11. package/dungeons/technical/scale-test.js +15 -4
  12. package/dungeons/technical/scd.js +24 -5
  13. package/dungeons/technical/simple.js +20 -5
  14. package/dungeons/technical/simplest.js +23 -6
  15. package/dungeons/technical/text-generation.js +22 -6
  16. package/dungeons/user/.gitkeep +0 -0
  17. package/dungeons/vertical/community-schema.json +1 -1
  18. package/dungeons/vertical/community.js +49 -28
  19. package/dungeons/vertical/devtools-schema.json +1 -1
  20. package/dungeons/vertical/devtools.js +50 -29
  21. package/dungeons/vertical/ecommerce.js +23 -6
  22. package/dungeons/vertical/education-schema.json +1 -1
  23. package/dungeons/vertical/education.js +29 -19
  24. package/dungeons/vertical/fintech-schema.json +1 -1
  25. package/dungeons/vertical/fintech.js +27 -8
  26. package/dungeons/vertical/fitness-schema.json +1 -1
  27. package/dungeons/vertical/fitness.js +48 -22
  28. package/dungeons/vertical/food-delivery-schema.json +1 -1
  29. package/dungeons/vertical/food-delivery.js +51 -12
  30. package/dungeons/vertical/gaming-schema.json +1 -1
  31. package/dungeons/vertical/gaming.js +41 -7
  32. package/dungeons/vertical/healthcare-schema.json +1 -1
  33. package/dungeons/vertical/healthcare.js +69 -40
  34. package/dungeons/vertical/insurance-application-schema.json +1 -1
  35. package/dungeons/vertical/insurance-application.js +26 -8
  36. package/dungeons/vertical/logistics-schema.json +1 -1
  37. package/dungeons/vertical/logistics.js +69 -41
  38. package/dungeons/vertical/marketplace-schema.json +1 -1
  39. package/dungeons/vertical/marketplace.js +44 -19
  40. package/dungeons/vertical/media.js +56 -15
  41. package/dungeons/vertical/rpg-schema.json +1 -1
  42. package/dungeons/vertical/rpg.js +38 -8
  43. package/dungeons/vertical/sass.js +24 -7
  44. package/dungeons/vertical/social.js +23 -7
  45. package/dungeons/vertical/travel-schema.json +1 -1
  46. package/dungeons/vertical/travel.js +50 -19
  47. package/index.js +3 -0
  48. package/lib/core/config-validator.js +13 -2
  49. package/lib/generators/events.js +3 -5
  50. package/lib/orchestrators/mixpanel-sender.js +64 -3
  51. package/lib/orchestrators/user-loop.js +20 -0
  52. package/lib/utils/utils.js +129 -9
  53. package/package.json +2 -2
  54. package/types.d.ts +5 -3
@@ -1,14 +1,21 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "harness-gaming";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "harness-gaming";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
 
13
20
  /** @typedef {import("../../types").Dungeon} Config */
14
21
 
@@ -164,10 +171,10 @@ const itemIds = v.range(1, 301).map(n => `item_${v.uid(7)}`);
164
171
 
165
172
  /** @type {Config} */
166
173
  const config = {
167
- token: "",
174
+ token,
168
175
  seed: SEED,
169
- numDays: days,
170
- numEvents: num_users * 120,
176
+ numDays: num_days,
177
+ numEvents: num_users * avg_events_per_user,
171
178
  numUsers: num_users,
172
179
  hasAnonIds: false,
173
180
  hasSessionIds: true,
@@ -519,7 +526,7 @@ const config = {
519
526
  ],
520
527
 
521
528
  superProps: {
522
- platform: [
529
+ Platform: [
523
530
  "PC",
524
531
  "Mac",
525
532
  "PlayStation",
@@ -562,6 +569,20 @@ const config = {
562
569
  "Ranger",
563
570
  "Paladin"
564
571
  ],
572
+ Platform: [
573
+ "PC",
574
+ "Mac",
575
+ "PlayStation",
576
+ "Xbox",
577
+ "Switch"
578
+ ],
579
+ graphics_quality: [
580
+ "Low",
581
+ "Medium",
582
+ "High",
583
+ "Ultra"
584
+ ],
585
+ subscription_tier: ["Free", "Free", "Free", "Premium", "Elite"],
565
586
  },
566
587
 
567
588
  groupKeys: [
@@ -595,7 +616,7 @@ const config = {
595
616
  */
596
617
  hook: function (record, type, meta) {
597
618
  const NOW = dayjs();
598
- const DATASET_START = NOW.subtract(days, 'days');
619
+ const DATASET_START = NOW.subtract(num_days, 'days');
599
620
  const CURSED_WEEK_START = DATASET_START.add(40, 'days');
600
621
  const CURSED_WEEK_END = DATASET_START.add(47, 'days');
601
622
  const LEGENDARY_WEAPON_RELEASE = DATASET_START.add(45, 'days');
@@ -642,6 +663,15 @@ const config = {
642
663
  // Hook #3, #4, #5, #6, #1: EVERYTHING - Complex behavioral patterns
643
664
  if (type === "everything") {
644
665
  const userEvents = record;
666
+ const profile = meta.profile;
667
+
668
+ // Stamp superProps from profile for consistency
669
+ userEvents.forEach(e => {
670
+ e.Platform = profile.Platform;
671
+ e.graphics_quality = profile.graphics_quality;
672
+ e.subscription_tier = profile.subscription_tier;
673
+ });
674
+
645
675
  const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
646
676
 
647
677
  // Track user behaviors
@@ -1,14 +1,21 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "harness-sass";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "harness-sass";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
 
13
20
  /** @typedef {import("../../types").Dungeon} Config */
14
21
 
@@ -248,10 +255,10 @@ const failedDeployUsers = new Map();
248
255
 
249
256
  /** @type {Config} */
250
257
  const config = {
251
- token: "",
258
+ token,
252
259
  seed: SEED,
253
- numDays: days,
254
- numEvents: num_users * 120,
260
+ numDays: num_days,
261
+ numEvents: num_users * avg_events_per_user,
255
262
  numUsers: num_users,
256
263
  hasAnonIds: false,
257
264
  hasSessionIds: true,
@@ -547,6 +554,8 @@ const config = {
547
554
  annual_contract_value: [0],
548
555
  customer_success_manager: [false],
549
556
  customer_health_score: u.weighNumRange(1, 100),
557
+ plan_tier: ["free", "free", "team", "team", "business", "enterprise"],
558
+ cloud_provider: ["aws", "gcp", "azure", "multi_cloud"],
550
559
  },
551
560
 
552
561
  groupKeys: [
@@ -580,7 +589,7 @@ const config = {
580
589
  */
581
590
  hook: function (record, type, meta) {
582
591
  const NOW = dayjs();
583
- const DATASET_START = NOW.subtract(days, "days");
592
+ const DATASET_START = NOW.subtract(num_days, "days");
584
593
 
585
594
  // ─────────────────────────────────────────────────────────────
586
595
  // Hook #1: END-OF-QUARTER SPIKE (event)
@@ -698,6 +707,14 @@ const config = {
698
707
  // ─────────────────────────────────────────────────────────────
699
708
  if (type === "everything") {
700
709
  const userEvents = record;
710
+ const profile = meta.profile;
711
+
712
+ // Stamp superProps from profile for consistency
713
+ userEvents.forEach(e => {
714
+ e.plan_tier = profile.plan_tier;
715
+ e.cloud_provider = profile.cloud_provider;
716
+ });
717
+
701
718
  if (userEvents && userEvents.length > 0) {
702
719
  const firstEvent = userEvents[0];
703
720
  const idHash = String(firstEvent.user_id || firstEvent.device_id).split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
@@ -1,14 +1,21 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "harness-social";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "harness-social";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
 
13
20
  /** @typedef {import("../../types").Dungeon} Config */
14
21
 
@@ -162,10 +169,10 @@ const postIds = v.range(1, 1001).map(n => `post_${v.uid(8)}`);
162
169
 
163
170
  /** @type {Config} */
164
171
  const config = {
165
- token: "",
172
+ token,
166
173
  seed: SEED,
167
- numDays: days,
168
- numEvents: num_users * 120,
174
+ numDays: num_days,
175
+ numEvents: num_users * avg_events_per_user,
169
176
  numUsers: num_users,
170
177
  hasAnonIds: false,
171
178
  hasSessionIds: true,
@@ -445,6 +452,8 @@ const config = {
445
452
  },
446
453
 
447
454
  userProps: {
455
+ app_version: ["4.0", "4.1", "4.2", "4.3", "5.0"],
456
+ account_type: ["personal", "creator", "business"],
448
457
  "follower_count": u.weighNumRange(0, 10000, 0.2, 50),
449
458
  "following_count": u.weighNumRange(0, 5000, 0.3, 100),
450
459
  "bio_length": u.weighNumRange(0, 160),
@@ -469,7 +478,7 @@ const config = {
469
478
 
470
479
  hook: function (record, type, meta) {
471
480
  const NOW = dayjs();
472
- const DATASET_START = NOW.subtract(days, 'days');
481
+ const DATASET_START = NOW.subtract(num_days, 'days');
473
482
  const ALGORITHM_CHANGE_DAY = DATASET_START.add(45, 'days');
474
483
  const REENGAGEMENT_START = DATASET_START.add(30, 'days');
475
484
 
@@ -529,6 +538,13 @@ const config = {
529
538
  const userEvents = record;
530
539
  if (!userEvents || userEvents.length === 0) return record;
531
540
 
541
+ // Stamp superProps from profile for consistency
542
+ const profile = meta.profile;
543
+ userEvents.forEach(e => {
544
+ e.app_version = profile.app_version;
545
+ e.account_type = profile.account_type;
546
+ });
547
+
532
548
  // Tracking variables for user patterns
533
549
  let postCreatedCount = 0;
534
550
  let followReceivedCount = 0;
@@ -530,7 +530,7 @@
530
530
  }
531
531
  ],
532
532
  "superProps": {
533
- "platform": [
533
+ "Platform": [
534
534
  "ios",
535
535
  "android",
536
536
  "web"
@@ -1,16 +1,23 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "dm4-travel";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "dm4-travel";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
  const NOW = dayjs();
13
- const DATASET_START = NOW.subtract(days, "days");
20
+ const DATASET_START = NOW.subtract(num_days, "days");
14
21
 
15
22
  /** @typedef {import("../../types").Dungeon} Config */
16
23
 
@@ -164,10 +171,10 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
164
171
  * with consistent, high-frequency booking patterns.
165
172
  *
166
173
  * ───────────────────────────────────────────────────────────────
167
- * 8. REPEAT DESTINATION CLUSTERING (funnel-pre hook)
174
+ * 8. REPEAT DESTINATION CLUSTERING (everything hook — event filtering)
168
175
  * ───────────────────────────────────────────────────────────────
169
- * PATTERN: Business travelers convert 1.3x better through the
170
- * search-to-book funnel (repeat bookings are habitual).
176
+ * PATTERN: Non-business/luxury users have ~25% of "booking completed"
177
+ * events dropped, simulating lower funnel conversion for casual segments.
171
178
  *
172
179
  * HOW TO FIND IT IN MIXPANEL:
173
180
  * Report 1: Conversion by Segment
@@ -197,10 +204,10 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
197
204
 
198
205
  /** @type {Config} */
199
206
  const config = {
200
- token: "",
207
+ token,
201
208
  seed: SEED,
202
- numDays: days,
203
- numEvents: num_users * 120,
209
+ numDays: num_days,
210
+ numEvents: num_users * avg_events_per_user,
204
211
  numUsers: num_users,
205
212
  hasAnonIds: false,
206
213
  hasSessionIds: true,
@@ -445,7 +452,7 @@ const config = {
445
452
 
446
453
  // ── SuperProps ──────────────────────────────────────────
447
454
  superProps: {
448
- platform: ["ios", "android", "web", "web"],
455
+ Platform: ["ios", "android", "web", "web"],
449
456
  membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
450
457
  },
451
458
 
@@ -456,6 +463,8 @@ const config = {
456
463
  company_name: ["none"],
457
464
  preferred_destination: chance.pickone.bind(chance, destinationCities),
458
465
  avg_budget_per_night: u.weighNumRange(50, 400, 0.4, 150),
466
+ Platform: ["ios", "android", "web", "web"],
467
+ membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
459
468
  },
460
469
 
461
470
  // ── Personas ──────────────────────────────────
@@ -632,14 +641,10 @@ const config = {
632
641
  }
633
642
 
634
643
  // ── HOOK 8: REPEAT DESTINATION CLUSTERING (funnel-pre) ─
644
+ // conversionRate modifications moved to everything hook (event filtering)
645
+ // to avoid dilution by organic events
635
646
  if (type === "funnel-pre") {
636
- if (meta && meta.profile) {
637
- if (meta.profile.customer_segment === "business_traveler") {
638
- record.conversionRate = Math.min(record.conversionRate * 1.3, 90);
639
- } else if (meta.profile.customer_segment === "luxury_seeker") {
640
- record.conversionRate = Math.min(record.conversionRate * 1.15, 85);
641
- }
642
- }
647
+ // prop-setting only; no conversionRate changes
643
648
  }
644
649
 
645
650
  // ── HOOK 1: WEEKEND LEISURE SURGE (event) ────────────
@@ -672,6 +677,33 @@ const config = {
672
677
  const events = record;
673
678
  if (!events.length) return record;
674
679
 
680
+ const profile = meta.profile;
681
+
682
+ // ─── Stamp superProps from profile (consistent per user) ───
683
+ const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
684
+ const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
685
+ if (stampPlatform || stampTier) {
686
+ events.forEach(e => {
687
+ if (stampPlatform) e.Platform = stampPlatform;
688
+ if (stampTier) e.membership_tier = stampTier;
689
+ });
690
+ }
691
+
692
+ // ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
693
+ // Drop ~25% of "booking completed" events for users who are NOT
694
+ // business_traveler or luxury_seeker to simulate their lower funnel
695
+ // conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
696
+ const segment = profile && profile.customer_segment;
697
+ if (segment !== "business_traveler" && segment !== "luxury_seeker") {
698
+ for (let i = events.length - 1; i >= 0; i--) {
699
+ if (events[i].event === "booking completed") {
700
+ if (chance.bool({ likelihood: 25 })) {
701
+ events.splice(i, 1);
702
+ }
703
+ }
704
+ }
705
+ }
706
+
675
707
  // ── HOOK 3: LOYALTY TIER UPGRADE PATH ────────────
676
708
  let bookingCount = 0;
677
709
  events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
@@ -694,7 +726,6 @@ const config = {
694
726
  }
695
727
 
696
728
  // ── HOOK 5: UPSELL SUCCESS BY SEGMENT ────────────
697
- const profile = meta.profile;
698
729
  if (profile && profile.customer_segment === "luxury_seeker") {
699
730
  const templateUpgrade = events.find(e => e.event === "room upgrade selected");
700
731
  if (templateUpgrade) {
package/index.js CHANGED
@@ -109,6 +109,9 @@ async function DUNGEON_MASTER(input, overrides = {}) {
109
109
  const textConfig = await loadFromText(value);
110
110
  return await runDungeon({ ...textConfig, ...overrides });
111
111
  }
112
+
113
+ default:
114
+ throw new Error(`Unexpected input type: ${type}`);
112
115
  }
113
116
  }
114
117
 
@@ -24,7 +24,6 @@ const FIXED_NOW = dayjs('2024-02-02').unix();
24
24
  function inferFunnels(events) {
25
25
  const createdFunnels = [];
26
26
  const firstEvents = events.filter((e) => e.isFirstEvent).map((e) => e.event);
27
- const strictEvents = events.filter((e) => e.isStrictEvent).map((e) => e.event);
28
27
  const usageEvents = events
29
28
  .filter((e) => !e.isFirstEvent && !e.isStrictEvent)
30
29
  .map((e) => e.event);
@@ -106,6 +105,7 @@ export function validateDungeonConfig(config) {
106
105
  lookupTables = [],
107
106
  hasAnonIds = false,
108
107
  hasSessionIds = false,
108
+ sessionTimeout = 30,
109
109
  format = "csv",
110
110
  token = null,
111
111
  region = "US",
@@ -140,6 +140,14 @@ export function validateDungeonConfig(config) {
140
140
  concurrency = 1;
141
141
  }
142
142
 
143
+ // Throw if token is the placeholder and nothing will be written to disk
144
+ if (token === "your-mixpanel-token" && !writeToDisk) {
145
+ throw new Error(
146
+ "No Mixpanel token set and writeToDisk is false — nothing useful will happen.\n" +
147
+ "Either set process.env.MP_TOKEN, change the token in the dungeon file, or set writeToDisk to true."
148
+ );
149
+ }
150
+
143
151
  // Auto-enable batch mode for large datasets to prevent OOM
144
152
  if (numEvents >= 2_000_000 && config.batchSize === undefined) {
145
153
  batchSize = 1_000_000;
@@ -148,7 +156,9 @@ export function validateDungeonConfig(config) {
148
156
 
149
157
  // Ensure defaults for deep objects
150
158
  if (!config.superProps) config.superProps = superProps;
151
- if (!config.userProps || Object.keys(config?.userProps || {})) config.userProps = userProps;
159
+ if (!config.userProps || Object.keys(config.userProps).length === 0) {
160
+ userProps = { spiritAnimal: chance.animal.bind(chance) };
161
+ }
152
162
 
153
163
  // Setting up "TIME"
154
164
  if (epochStart && !numDays) numDays = dayjs.unix(epochEnd).diff(dayjs.unix(epochStart), "day");
@@ -356,6 +366,7 @@ export function validateDungeonConfig(config) {
356
366
  lookupTables,
357
367
  hasAnonIds,
358
368
  hasSessionIds,
369
+ sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
359
370
  format,
360
371
  token,
361
372
  region,
@@ -116,14 +116,12 @@ export async function makeEvent(
116
116
  eventTemplate.time = dayjs.unix(Math.min(shiftedTimestamp, context.MAX_TIME)).toISOString();
117
117
  }
118
118
 
119
- // Add anonymous and session identifiers
119
+ // Add anonymous identifiers
120
120
  if (anonymousIds.length) {
121
121
  eventTemplate.device_id = u.pickRandom(anonymousIds);
122
122
  }
123
-
124
- if (sessionIds.length) {
125
- eventTemplate.session_id = u.pickRandom(sessionIds);
126
- }
123
+
124
+ // Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
127
125
 
128
126
  // Sometimes add user_id (for attribution modeling)
129
127
  if (!isFirstEvent && chance.bool({ likelihood: 42 })) {
@@ -48,9 +48,10 @@ export async function sendToMixpanel(context) {
48
48
  const commonOpts = {
49
49
  region,
50
50
  fixData: true,
51
+ v2_compat: true,
51
52
  verbose: false,
52
53
  forceStream: true,
53
- strict: true,
54
+ strict: false,
54
55
  epochEnd: dayjs().unix(),
55
56
  dryRun: false,
56
57
  abridged: false,
@@ -159,7 +160,6 @@ export async function sendToMixpanel(context) {
159
160
  const imported = await mp(creds, groupEventDataToImport, {
160
161
  recordType: "event",
161
162
  ...commonOpts,
162
- strict: false
163
163
  });
164
164
  log(` -> ${comma(imported.success)} group events sent\n`);
165
165
  importResults.groupEvents = imported;
@@ -198,7 +198,6 @@ export async function sendToMixpanel(context) {
198
198
  scdKey,
199
199
  scdType,
200
200
  scdLabel: `${scdKey}`,
201
- fixData: true,
202
201
  ...commonOpts,
203
202
  };
204
203
 
@@ -227,6 +226,9 @@ export async function sendToMixpanel(context) {
227
226
  }
228
227
  }
229
228
 
229
+ importResults.problems = collectProblems(importResults);
230
+ logProblems(importResults.problems);
231
+
230
232
  log(`${'─'.repeat(50)}\n`);
231
233
 
232
234
  // Clean up batch files if needed
@@ -263,4 +265,63 @@ export async function sendToMixpanel(context) {
263
265
  let _verbose = true;
264
266
  function log(message) {
265
267
  if (_verbose) console.log(message);
268
+ }
269
+
270
+ /**
271
+ * Walk importResults and collect any failure/error indicators per import.
272
+ * Returns an array of problem objects (empty when all imports succeeded).
273
+ * @param {Object} importResults
274
+ * @returns {Array<{label: string, failed: number, unparsable: number, serverErrors: number, clientErrors: number, errorCount: number, error?: string, errors?: any[]}>}
275
+ */
276
+ function collectProblems(importResults) {
277
+ const entries = [];
278
+ for (const [key, value] of Object.entries(importResults)) {
279
+ if (!value || key === 'problems') continue;
280
+ if (Array.isArray(value)) {
281
+ value.forEach((v, i) => entries.push([`${key}[${i}]`, v]));
282
+ } else {
283
+ entries.push([key, value]);
284
+ }
285
+ }
286
+
287
+ const problems = [];
288
+ for (const [label, result] of entries) {
289
+ if (!result || typeof result !== 'object') continue;
290
+ const failed = result.failed || 0;
291
+ const unparsable = result.unparsable || 0;
292
+ const serverErrors = result.serverErrors || 0;
293
+ const clientErrors = result.clientErrors || 0;
294
+ const errorCount = Array.isArray(result.errors) ? result.errors.length : 0;
295
+ const error = result.error;
296
+ if (failed || unparsable || serverErrors || clientErrors || errorCount || error) {
297
+ problems.push({ label, failed, unparsable, serverErrors, clientErrors, errorCount, error, errors: result.errors });
298
+ }
299
+ }
300
+ return problems;
301
+ }
302
+
303
+ /**
304
+ * Print the compact problem report. Silent when there are no problems.
305
+ * @param {ReturnType<typeof collectProblems>} problems
306
+ */
307
+ function logProblems(problems) {
308
+ if (!problems || problems.length === 0) return;
309
+
310
+ log(` Problems`);
311
+ for (const p of problems) {
312
+ const parts = [];
313
+ if (p.failed) parts.push(`${comma(p.failed)} failed`);
314
+ if (p.unparsable) parts.push(`${comma(p.unparsable)} unparsable`);
315
+ if (p.serverErrors) parts.push(`${comma(p.serverErrors)} 5xx (retried)`);
316
+ if (p.clientErrors) parts.push(`${comma(p.clientErrors)} client errors (retried)`);
317
+ if (p.errorCount) parts.push(`${comma(p.errorCount)} error records`);
318
+ if (p.error) parts.push(`error: ${p.error}`);
319
+ log(` -> ${p.label}: ${parts.join(', ')}`);
320
+ if (Array.isArray(p.errors) && p.errors.length > 0) {
321
+ const sample = p.errors[0];
322
+ const summary = typeof sample === 'string' ? sample : JSON.stringify(sample).slice(0, 200);
323
+ log(` sample: ${summary}`);
324
+ }
325
+ }
326
+ log('');
266
327
  }
@@ -359,6 +359,8 @@ export async function userLoop(context) {
359
359
 
360
360
  // ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
361
361
  let userChurned = false;
362
+ const sessionTimeout = config.sessionTimeout || 30;
363
+
362
364
  while (numEventsPreformed < numEventsThisUserWillPreform && !cancelled) {
363
365
  let newEvents;
364
366
  if (usageFunnels.length && userConverted) {
@@ -448,6 +450,24 @@ export async function userLoop(context) {
448
450
  }
449
451
  }
450
452
 
453
+ // Session clustering: redistribute events into temporal bursts, then assign session IDs
454
+ if (hasSessionIds && usersEvents.length > 0) {
455
+ const soupCfg = /** @type {import('../../types').SoupConfig} */ (config.soup) || {};
456
+ const defaultPeaks = Math.max(5, (config.numDays || 30) * 2);
457
+ const { mean: soupMean = 0, deviation: soupDev = 2, peaks: soupPeaks = defaultPeaks,
458
+ dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD } = soupCfg;
459
+
460
+ u.bunchIntoSessions(usersEvents, sessionTimeout, {
461
+ earliestTime: userFirstEventTime,
462
+ latestTime: context.FIXED_NOW,
463
+ peaks: soupPeaks, deviation: soupDev, mean: soupMean,
464
+ dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD,
465
+ timeShiftSeconds: context.TIME_SHIFT_SECONDS,
466
+ maxTime: context.MAX_TIME
467
+ });
468
+ u.assignSessionIds(usersEvents, sessionTimeout);
469
+ }
470
+
451
471
  // Hook for processing all user events (hooks override everything)
452
472
  if (config.hook) {
453
473
  const newEvents = await config.hook(usersEvents, "everything", {