@ak--47/dungeon-master 1.3.1 → 1.4.1
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/CHANGELOG.md +58 -0
- package/dungeons/technical/hook-helpers-verify.js +89 -0
- package/dungeons/technical/identity-model-verify.js +47 -0
- package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
- package/dungeons/technical/pattern-attributed-by-source.js +42 -0
- package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
- package/dungeons/technical/pattern-funnel-frequency.js +54 -0
- package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
- package/dungeons/vertical/ai-platform.js +45 -52
- package/dungeons/vertical/community.js +11 -8
- package/dungeons/vertical/crypto.js +25 -24
- package/dungeons/vertical/dating.js +56 -48
- package/dungeons/vertical/devtools.js +25 -18
- package/dungeons/vertical/ecommerce.js +42 -38
- package/dungeons/vertical/education.js +24 -9
- package/dungeons/vertical/fintech.js +13 -8
- package/dungeons/vertical/fitness.js +73 -122
- package/dungeons/vertical/food-delivery.js +18 -19
- package/dungeons/vertical/gaming.js +19 -20
- package/dungeons/vertical/healthcare.js +11 -8
- package/dungeons/vertical/insurance-application.js +6 -3
- package/dungeons/vertical/logistics.js +15 -9
- package/dungeons/vertical/marketplace.js +36 -27
- package/dungeons/vertical/media.js +27 -25
- package/dungeons/vertical/real-estate.js +18 -7
- package/dungeons/vertical/sass.js +84 -68
- package/dungeons/vertical/social.js +46 -47
- package/dungeons/vertical/travel.js +8 -5
- package/index.js +17 -71
- package/lib/core/config-validator.js +143 -164
- package/lib/core/storage.js +5 -1
- package/lib/generators/events.js +49 -93
- package/lib/generators/funnels.js +202 -91
- package/lib/hook-helpers/_internal.js +23 -0
- package/lib/hook-helpers/cohort.js +124 -0
- package/lib/hook-helpers/identity.js +56 -0
- package/lib/hook-helpers/index.js +44 -0
- package/lib/hook-helpers/inject.js +99 -0
- package/lib/hook-helpers/mutate.js +151 -0
- package/lib/hook-helpers/timing.js +99 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
- package/lib/hook-patterns/attributed-by-source.js +72 -0
- package/lib/hook-patterns/frequency-by-frequency.js +46 -0
- package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
- package/lib/hook-patterns/index.js +14 -0
- package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
- package/lib/orchestrators/mixpanel-sender.js +46 -51
- package/lib/orchestrators/user-loop.js +119 -269
- package/lib/utils/utils.js +39 -16
- package/lib/verify/emulate-breakdown.js +281 -0
- package/lib/verify/index.js +12 -0
- package/lib/verify/verify-dungeon.js +61 -0
- package/package.json +6 -4
- package/types.d.ts +404 -212
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "dm4-logistics";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -31,7 +31,7 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
|
|
|
31
31
|
* businesses to track stock levels, purchase orders, suppliers,
|
|
32
32
|
* shipments, and quality inspections across multiple warehouses.
|
|
33
33
|
*
|
|
34
|
-
* -
|
|
34
|
+
* - 10,000 users over 120 days
|
|
35
35
|
* - Multi-tier system: enterprise (15%), mid-market (35%), small business (40%), trial (10%)
|
|
36
36
|
* - Core loop: sign up -> check inventory -> create PO -> receive order -> track shipment
|
|
37
37
|
* - Revenue: free_trial / starter ($49) / professional ($199) / enterprise ($599)
|
|
@@ -65,7 +65,7 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
|
|
|
65
65
|
* 1. MONTH-END REPORTING SURGE (event hook)
|
|
66
66
|
* -------------------------------------------------------------------
|
|
67
67
|
*
|
|
68
|
-
* PATTERN: Reports generated on calendar days 28-31 have
|
|
68
|
+
* PATTERN: Reports generated on calendar days 28-31 have 2.5x the
|
|
69
69
|
* report_pages. Simulates end-of-month compliance and audit reporting.
|
|
70
70
|
*
|
|
71
71
|
* HOW TO FIND IT IN MIXPANEL:
|
|
@@ -309,14 +309,16 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
|
|
|
309
309
|
|
|
310
310
|
/** @type {Config} */
|
|
311
311
|
const config = {
|
|
312
|
+
version: 2,
|
|
312
313
|
token,
|
|
313
314
|
seed: SEED,
|
|
314
315
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
315
|
-
datasetEnd: "2026-
|
|
316
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
316
317
|
// numDays: num_days,
|
|
317
318
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
318
319
|
numUsers: num_users,
|
|
319
|
-
hasAnonIds:
|
|
320
|
+
hasAnonIds: true,
|
|
321
|
+
avgDevicePerUser: 2,
|
|
320
322
|
hasSessionIds: true,
|
|
321
323
|
format: "json",
|
|
322
324
|
gzip: true,
|
|
@@ -349,6 +351,7 @@ const config = {
|
|
|
349
351
|
event: "account created",
|
|
350
352
|
weight: 1,
|
|
351
353
|
isFirstEvent: true,
|
|
354
|
+
isAuthEvent: true,
|
|
352
355
|
properties: {
|
|
353
356
|
referral_source: ["organic", "partner_referral", "google_search", "trade_show", "linkedin"],
|
|
354
357
|
},
|
|
@@ -771,6 +774,9 @@ const config = {
|
|
|
771
774
|
} else if (record.company_tier === "mid_market") {
|
|
772
775
|
record.warehouse_count = chance.integer({ min: 2, max: 6 });
|
|
773
776
|
record.employee_count = chance.integer({ min: 20, max: 200 });
|
|
777
|
+
} else if (record.company_tier === "small_business") {
|
|
778
|
+
record.warehouse_count = chance.integer({ min: 1, max: 3 });
|
|
779
|
+
record.employee_count = chance.integer({ min: 5, max: 80 });
|
|
774
780
|
} else if (record.company_tier === "trial") {
|
|
775
781
|
record.warehouse_count = 1;
|
|
776
782
|
record.employee_count = chance.integer({ min: 1, max: 10 });
|
|
@@ -818,7 +824,7 @@ const config = {
|
|
|
818
824
|
if (e.event === 'report generated') {
|
|
819
825
|
const dayOfMonth = new Date(e.time).getUTCDate();
|
|
820
826
|
if (dayOfMonth >= 28) {
|
|
821
|
-
e.report_pages = Math.floor((e.report_pages || 20) * 2);
|
|
827
|
+
e.report_pages = Math.floor((e.report_pages || 20) * 2.5);
|
|
822
828
|
}
|
|
823
829
|
}
|
|
824
830
|
}
|
|
@@ -896,7 +902,7 @@ const config = {
|
|
|
896
902
|
|
|
897
903
|
// -- HOOK 9: INVENTORY-CHECK MAGIC NUMBER (no flags) ------
|
|
898
904
|
// Sweet 5-15 inventory checks → +25% PO quantity.
|
|
899
|
-
// Over 16+ → drop
|
|
905
|
+
// Over 16+ → drop 45% of PO created events.
|
|
900
906
|
const invCheckCount = record.filter(e => e.event === 'inventory checked').length;
|
|
901
907
|
if (invCheckCount >= 5 && invCheckCount <= 15) {
|
|
902
908
|
record.forEach(e => {
|
|
@@ -906,7 +912,7 @@ const config = {
|
|
|
906
912
|
});
|
|
907
913
|
} else if (invCheckCount >= 16) {
|
|
908
914
|
for (let i = record.length - 1; i >= 0; i--) {
|
|
909
|
-
if (record[i].event === 'purchase order created' && chance.bool({ likelihood:
|
|
915
|
+
if (record[i].event === 'purchase order created' && chance.bool({ likelihood: 45 })) {
|
|
910
916
|
record.splice(i, 1);
|
|
911
917
|
}
|
|
912
918
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "dm4-marketplace";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -303,14 +303,16 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
|
303
303
|
|
|
304
304
|
/** @type {Config} */
|
|
305
305
|
const config = {
|
|
306
|
+
version: 2,
|
|
306
307
|
token,
|
|
307
308
|
seed: SEED,
|
|
308
309
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
309
|
-
datasetEnd: "2026-
|
|
310
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
310
311
|
// numDays: num_days,
|
|
311
312
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
312
313
|
numUsers: num_users,
|
|
313
|
-
hasAnonIds:
|
|
314
|
+
hasAnonIds: true,
|
|
315
|
+
avgDevicePerUser: 2,
|
|
314
316
|
hasSessionIds: true,
|
|
315
317
|
format: "json",
|
|
316
318
|
gzip: true,
|
|
@@ -343,6 +345,7 @@ const config = {
|
|
|
343
345
|
event: "account created",
|
|
344
346
|
weight: 1,
|
|
345
347
|
isFirstEvent: true,
|
|
348
|
+
isAuthEvent: true,
|
|
346
349
|
properties: {
|
|
347
350
|
signup_source: ["organic", "google_shopping", "tiktok", "seller_referral", "email_campaign", "word_of_mouth"],
|
|
348
351
|
},
|
|
@@ -882,38 +885,44 @@ const config = {
|
|
|
882
885
|
}
|
|
883
886
|
|
|
884
887
|
// ── HOOK 5: RESPONSE TIME → CONVERSION ───────────
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
// threshold of 2h was too tight — almost no users qualified.)
|
|
888
|
+
// Users with low avg response_time on messages get extra
|
|
889
|
+
// offer_accepted clones; slow responders lose half their accepts.
|
|
888
890
|
const messages = events.filter(e => e.event === "message sent" && e.response_time_hours);
|
|
889
891
|
if (messages.length > 0) {
|
|
890
892
|
const avgResponseTime = messages.reduce((sum, m) => sum + m.response_time_hours, 0) / messages.length;
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
|
|
902
|
-
});
|
|
903
|
-
}
|
|
893
|
+
const templateOffer = events.find(e => e.event === "offer accepted");
|
|
894
|
+
if (avgResponseTime <= 11 && templateOffer) {
|
|
895
|
+
// Fast responders: clone every offer_received into an offer_accepted
|
|
896
|
+
const offers = events.filter(e => e.event === "offer received");
|
|
897
|
+
offers.forEach(offer => {
|
|
898
|
+
events.push({
|
|
899
|
+
...templateOffer,
|
|
900
|
+
time: dayjs(offer.time).add(chance.integer({ min: 1, max: 4 }), "hours").toISOString(),
|
|
901
|
+
user_id: offer.user_id,
|
|
902
|
+
response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
|
|
904
903
|
});
|
|
904
|
+
});
|
|
905
|
+
} else if (avgResponseTime >= 25 && templateOffer) {
|
|
906
|
+
// Slow responders: drop half of offer_accepted events
|
|
907
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
908
|
+
if (events[i].event === "offer accepted" && chance.bool({ likelihood: 50 })) {
|
|
909
|
+
events.splice(i, 1);
|
|
910
|
+
}
|
|
905
911
|
}
|
|
906
912
|
}
|
|
907
913
|
}
|
|
908
914
|
|
|
909
915
|
// ── HOOK 6: NEW SELLER CHURN ─────────────────────
|
|
910
|
-
// New sellers
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
916
|
+
// New sellers lose 50% of events after their first 14 days.
|
|
917
|
+
// Uses user's first event as anchor (not dataset start).
|
|
918
|
+
if (profile && profile.segment === "new_seller") {
|
|
919
|
+
const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
|
|
920
|
+
if (firstEventTime) {
|
|
921
|
+
const userDay14 = firstEventTime.add(14, "days");
|
|
922
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
923
|
+
if (dayjs(events[i].time).isAfter(userDay14) && chance.bool({ likelihood: 50 })) {
|
|
924
|
+
events.splice(i, 1);
|
|
925
|
+
}
|
|
917
926
|
}
|
|
918
927
|
}
|
|
919
928
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "harness-media";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -29,7 +29,7 @@ const chance = u.initChance(SEED);
|
|
|
29
29
|
* content with configurable playback options, rate and share content, and manage family
|
|
30
30
|
* profiles under a single account.
|
|
31
31
|
*
|
|
32
|
-
* -
|
|
32
|
+
* - 10,000 users over 120 days
|
|
33
33
|
* - ~600,000 events across 17 event types
|
|
34
34
|
* - 9 funnels (onboarding, content discovery, engagement loop, search, watchlist, etc.)
|
|
35
35
|
* - Subscription tiers: Free (ad-supported), Standard ($9.99/mo), Premium ($14.99/mo)
|
|
@@ -271,14 +271,16 @@ const blockbusterId = `blockbuster_${v.uid(8)}`;
|
|
|
271
271
|
|
|
272
272
|
/** @type {Config} */
|
|
273
273
|
const config = {
|
|
274
|
+
version: 2,
|
|
274
275
|
token,
|
|
275
276
|
seed: SEED,
|
|
276
277
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
277
|
-
datasetEnd: "2026-
|
|
278
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
278
279
|
// numDays: num_days,
|
|
279
280
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
280
281
|
numUsers: num_users,
|
|
281
|
-
hasAnonIds:
|
|
282
|
+
hasAnonIds: true,
|
|
283
|
+
avgDevicePerUser: 2,
|
|
282
284
|
hasSessionIds: true,
|
|
283
285
|
format: "json",
|
|
284
286
|
gzip: true,
|
|
@@ -368,6 +370,7 @@ const config = {
|
|
|
368
370
|
event: "account created",
|
|
369
371
|
weight: 1,
|
|
370
372
|
isFirstEvent: true,
|
|
373
|
+
isAuthEvent: true,
|
|
371
374
|
properties: {
|
|
372
375
|
"signup_source": ["organic", "referral", "trial_offer", "ad"],
|
|
373
376
|
"plan_selected": ["free", "standard", "premium"],
|
|
@@ -563,26 +566,6 @@ const config = {
|
|
|
563
566
|
}
|
|
564
567
|
|
|
565
568
|
if (type === "event") {
|
|
566
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
567
|
-
const BLOCKBUSTER_START = datasetStart.add(50, 'days');
|
|
568
|
-
const BLOCKBUSTER_END = datasetStart.add(65, 'days');
|
|
569
|
-
const EVENT_TIME = dayjs(record.time);
|
|
570
|
-
|
|
571
|
-
// Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
|
|
572
|
-
// switch to the blockbuster id. Mutates existing content_id/content_type props.
|
|
573
|
-
if (record.event === "content selected" || record.event === "playback started") {
|
|
574
|
-
if (EVENT_TIME.isAfter(BLOCKBUSTER_START) && EVENT_TIME.isBefore(BLOCKBUSTER_END) && chance.bool({ likelihood: 20 })) {
|
|
575
|
-
record.content_type = "movie";
|
|
576
|
-
record.content_id = blockbusterId;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
if (record.event === "content rated") {
|
|
580
|
-
if (EVENT_TIME.isAfter(BLOCKBUSTER_START) && EVENT_TIME.isBefore(BLOCKBUSTER_END) && chance.bool({ likelihood: 20 })) {
|
|
581
|
-
record.rating = chance.integer({ min: 4, max: 5 });
|
|
582
|
-
record.content_id = blockbusterId;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
569
|
// Hook #6: KIDS PROFILE SAFETY — 15% of selections/starts get genre
|
|
587
570
|
// restricted to animation or documentary. Mutates existing genre prop.
|
|
588
571
|
if (chance.bool({ likelihood: 15 })) {
|
|
@@ -611,6 +594,25 @@ const config = {
|
|
|
611
594
|
});
|
|
612
595
|
}
|
|
613
596
|
|
|
597
|
+
// Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
|
|
598
|
+
// switch to the blockbuster id. Mutates existing content_id/content_type props.
|
|
599
|
+
// (Moved from event hook to everything hook per L1: temporal checks belong here.)
|
|
600
|
+
const BLOCKBUSTER_START = datasetStart.add(50, 'days');
|
|
601
|
+
const BLOCKBUSTER_END = datasetStart.add(65, 'days');
|
|
602
|
+
for (const e of userEvents) {
|
|
603
|
+
const eventTime = dayjs(e.time);
|
|
604
|
+
if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
|
|
605
|
+
if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: 20 })) {
|
|
606
|
+
e.content_type = "movie";
|
|
607
|
+
e.content_id = blockbusterId;
|
|
608
|
+
}
|
|
609
|
+
if (e.event === "content rated" && chance.bool({ likelihood: 20 })) {
|
|
610
|
+
e.rating = chance.integer({ min: 4, max: 5 });
|
|
611
|
+
e.content_id = blockbusterId;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
614
616
|
// Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
|
|
615
617
|
// No flag — analyst breaks down by day of week.
|
|
616
618
|
for (const e of userEvents) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "homenest";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 0.53;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -267,14 +267,16 @@ const chance = u.initChance(SEED);
|
|
|
267
267
|
|
|
268
268
|
/** @type {Config} */
|
|
269
269
|
const config = {
|
|
270
|
+
version: 2,
|
|
270
271
|
token,
|
|
271
272
|
seed: SEED,
|
|
272
273
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
273
|
-
datasetEnd: "2026-
|
|
274
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
274
275
|
// numDays: num_days,
|
|
275
276
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
276
277
|
numUsers: num_users,
|
|
277
|
-
hasAnonIds:
|
|
278
|
+
hasAnonIds: true,
|
|
279
|
+
avgDevicePerUser: 2,
|
|
278
280
|
hasSessionIds: true,
|
|
279
281
|
format: "json",
|
|
280
282
|
gzip: true,
|
|
@@ -333,6 +335,7 @@ const config = {
|
|
|
333
335
|
event: "account created",
|
|
334
336
|
weight: 1,
|
|
335
337
|
isFirstEvent: true,
|
|
338
|
+
isAuthEvent: true,
|
|
336
339
|
properties: {
|
|
337
340
|
signup_method: ["email", "google", "apple", "facebook"],
|
|
338
341
|
}
|
|
@@ -560,9 +563,7 @@ const config = {
|
|
|
560
563
|
if (inSpring && e.event === "tour scheduled" && typeof e.duration_mins === "number") {
|
|
561
564
|
e.duration_mins = Math.round(e.duration_mins * 3);
|
|
562
565
|
}
|
|
563
|
-
|
|
564
|
-
e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
|
|
565
|
-
}
|
|
566
|
+
// Spring offer_price boost moved to end (after all cloning)
|
|
566
567
|
if (e.event === "mortgage pre-approval") {
|
|
567
568
|
if ((t.isAfter(shockStart) || t.isSame(shockStart)) && t.isBefore(shockEnd)) {
|
|
568
569
|
e.mortgage_rate = 7.5;
|
|
@@ -772,6 +773,16 @@ const config = {
|
|
|
772
773
|
}
|
|
773
774
|
}
|
|
774
775
|
}
|
|
776
|
+
|
|
777
|
+
// HOOK 1 (cont): Spring offer_price boost — runs AFTER all cloning
|
|
778
|
+
// so cloned offers in the spring window also get the 2.5x boost
|
|
779
|
+
userEvents.forEach(e => {
|
|
780
|
+
if (e.event !== "offer submitted") return;
|
|
781
|
+
const t = dayjs.utc(e.time);
|
|
782
|
+
if ((t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd)) {
|
|
783
|
+
e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
775
786
|
}
|
|
776
787
|
|
|
777
788
|
return record;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "harness-sass";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -67,7 +67,7 @@ const chance = u.initChance(SEED);
|
|
|
67
67
|
* 1. END-OF-QUARTER SPIKE (event)
|
|
68
68
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
69
69
|
*
|
|
70
|
-
* PATTERN: Days
|
|
70
|
+
* PATTERN: Days 100-110 billing events shift event_type toward "plan_upgraded"
|
|
71
71
|
* 40% of the time and team member invitations are duplicated 50% of the time.
|
|
72
72
|
* No flag — discover via line chart by day.
|
|
73
73
|
*
|
|
@@ -79,7 +79,7 @@ const chance = u.initChance(SEED);
|
|
|
79
79
|
* - Measure: Total
|
|
80
80
|
* - Filter: event_type = "plan_upgraded"
|
|
81
81
|
* - Line chart by day
|
|
82
|
-
* - Expected: ~4x normal upgrade volume during days
|
|
82
|
+
* - Expected: ~4x normal upgrade volume during days 100-110
|
|
83
83
|
*
|
|
84
84
|
* Report 2: Team Expansion Surge
|
|
85
85
|
* - Report type: Insights
|
|
@@ -322,11 +322,15 @@ const config = {
|
|
|
322
322
|
token,
|
|
323
323
|
seed: SEED,
|
|
324
324
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
325
|
-
datasetEnd: "2026-
|
|
325
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
326
326
|
// numDays: num_days,
|
|
327
327
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
328
328
|
numUsers: num_users,
|
|
329
|
-
|
|
329
|
+
// Phase 2 identity model — B2B SaaS reference. Engineers commonly use 1-2
|
|
330
|
+
// devices (desktop + work laptop). avgDevicePerUser:2 puts a meaningful
|
|
331
|
+
// per-session sticky-device pattern in Mixpanel device dashboards.
|
|
332
|
+
hasAnonIds: true,
|
|
333
|
+
avgDevicePerUser: 2,
|
|
330
334
|
hasSessionIds: true,
|
|
331
335
|
format: "json",
|
|
332
336
|
gzip: true,
|
|
@@ -360,10 +364,13 @@ const config = {
|
|
|
360
364
|
|
|
361
365
|
funnels: [
|
|
362
366
|
{
|
|
367
|
+
// First funnel — `workspace created` is the auth event for B2B users.
|
|
368
|
+
// Models real B2B onboarding: most teams take ≤1 retry before sticking.
|
|
363
369
|
sequence: ["workspace created", "service deployed", "dashboard viewed"],
|
|
364
370
|
isFirstFunnel: true,
|
|
365
371
|
conversionRate: 70,
|
|
366
372
|
timeToConvert: 2,
|
|
373
|
+
attempts: { min: 0, max: 1 },
|
|
367
374
|
},
|
|
368
375
|
{
|
|
369
376
|
// Daily monitoring: dashboards, queries, API calls (most common)
|
|
@@ -421,6 +428,10 @@ const config = {
|
|
|
421
428
|
event: "workspace created",
|
|
422
429
|
weight: 1,
|
|
423
430
|
isFirstEvent: true,
|
|
431
|
+
// Phase 2 identity: workspace creation is the B2B equivalent of Sign Up
|
|
432
|
+
// — engine stamps user_id+device_id on this event when it fires inside
|
|
433
|
+
// the user's first funnel.
|
|
434
|
+
isAuthEvent: true,
|
|
424
435
|
properties: {
|
|
425
436
|
company_size: ["startup", "smb", "mid_market", "enterprise"],
|
|
426
437
|
industry: ["tech", "finance", "healthcare", "retail", "media"],
|
|
@@ -500,7 +511,7 @@ const config = {
|
|
|
500
511
|
weight: 5,
|
|
501
512
|
properties: {
|
|
502
513
|
service_id: serviceIds,
|
|
503
|
-
scale_direction: ["
|
|
514
|
+
scale_direction: ["up", "up", "up", "down"],
|
|
504
515
|
previous_capacity: u.weighNumRange(1, 100),
|
|
505
516
|
new_capacity: u.weighNumRange(1, 100),
|
|
506
517
|
auto_scaled: [false, false, false, false, false, false, true],
|
|
@@ -553,7 +564,7 @@ const config = {
|
|
|
553
564
|
event: "billing event",
|
|
554
565
|
weight: 3,
|
|
555
566
|
properties: {
|
|
556
|
-
event_type: ["invoice_generated", "payment_received", "payment_failed", "plan_upgraded", "plan_downgraded"],
|
|
567
|
+
event_type: ["invoice_generated", "invoice_generated", "payment_received", "payment_received", "payment_received", "payment_failed", "plan_upgraded", "plan_downgraded"],
|
|
557
568
|
amount: u.weighNumRange(99, 25000),
|
|
558
569
|
}
|
|
559
570
|
},
|
|
@@ -632,7 +643,7 @@ const config = {
|
|
|
632
643
|
*
|
|
633
644
|
* This hook function creates 8 deliberate patterns in the data:
|
|
634
645
|
*
|
|
635
|
-
* 1. END-OF-QUARTER SPIKE: Days
|
|
646
|
+
* 1. END-OF-QUARTER SPIKE: Days 100-110 drive plan upgrades and team expansion
|
|
636
647
|
* 2. CHURNED ACCOUNT SILENCING: ~10% of users go completely silent after month 1
|
|
637
648
|
* 3. ALERT ESCALATION REPLACEMENT: Critical alerts become "incident created" events
|
|
638
649
|
* 4. INTEGRATION USERS SUCCEED: Slack+PagerDuty users resolve incidents 50-60% faster
|
|
@@ -642,29 +653,7 @@ const config = {
|
|
|
642
653
|
* 8. ENTERPRISE VS STARTUP: Company size determines seat count, ACV, and health score
|
|
643
654
|
*/
|
|
644
655
|
hook: function (record, type, meta) {
|
|
645
|
-
//
|
|
646
|
-
// flip event_type to plan_upgraded; team-member-invited events get
|
|
647
|
-
// 50% chance of duplicate clone with unique time. No flag.
|
|
648
|
-
if (type === "event") {
|
|
649
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
650
|
-
const EVENT_TIME = dayjs(record.time);
|
|
651
|
-
const dayInDataset = EVENT_TIME.diff(datasetStart, "days", true);
|
|
652
|
-
|
|
653
|
-
if (record.event === "billing event") {
|
|
654
|
-
if (dayInDataset >= 80 && dayInDataset <= 90 && chance.bool({ likelihood: 40 })) {
|
|
655
|
-
record.event_type = "plan_upgraded";
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
if (record.event === "team member invited" && dayInDataset >= 80 && dayInDataset <= 90 && chance.bool({ likelihood: 50 })) {
|
|
660
|
-
return {
|
|
661
|
-
...record,
|
|
662
|
-
time: EVENT_TIME.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
663
|
-
role: chance.pickone(["editor", "viewer"]),
|
|
664
|
-
invitation_method: chance.pickone(["email", "sso", "slack"]),
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
}
|
|
656
|
+
// (Hook 1a moved to everything hook for reliable datasetStart access)
|
|
668
657
|
|
|
669
658
|
// HOOK 3: ALERT ESCALATION REPLACEMENT (event) — critical/emergency
|
|
670
659
|
// alerts sometimes become incident-created events. Real product flow.
|
|
@@ -699,40 +688,6 @@ const config = {
|
|
|
699
688
|
}
|
|
700
689
|
}
|
|
701
690
|
|
|
702
|
-
// HOOK 7: FAILED DEPLOYMENT RECOVERY (event) — failed deploy records
|
|
703
|
-
// user, then next successful deploy gets duration_sec * 1.5. No flag.
|
|
704
|
-
if (type === "event") {
|
|
705
|
-
if (record.event === "deployment pipeline run") {
|
|
706
|
-
if (record.status === "failed") {
|
|
707
|
-
failedDeployUsers.set(record.user_id, true);
|
|
708
|
-
} else if (record.status === "success" && failedDeployUsers.has(record.user_id)) {
|
|
709
|
-
record.duration_sec = Math.floor((record.duration_sec || 300) * 1.5);
|
|
710
|
-
failedDeployUsers.delete(record.user_id);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// HOOK 9 (T2C): INCIDENT RESPONSE TIME-TO-CONVERT (funnel-post)
|
|
716
|
-
// Enterprise tier completes Incident Response funnel 1.4x faster
|
|
717
|
-
// (factor 0.71); Startup 1.25x slower (factor 1.25).
|
|
718
|
-
if (type === "funnel-post") {
|
|
719
|
-
const segment = meta?.profile?.company_size;
|
|
720
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
721
|
-
const factor = (
|
|
722
|
-
segment === "enterprise" ? 0.71 :
|
|
723
|
-
segment === "startup" ? 1.25 :
|
|
724
|
-
1.0
|
|
725
|
-
);
|
|
726
|
-
if (factor !== 1.0) {
|
|
727
|
-
for (let i = 1; i < record.length; i++) {
|
|
728
|
-
const prev = dayjs(record[i - 1].time);
|
|
729
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
730
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
691
|
if (type === "everything") {
|
|
737
692
|
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
738
693
|
const userEvents = record;
|
|
@@ -743,6 +698,33 @@ const config = {
|
|
|
743
698
|
e.cloud_provider = profile.cloud_provider;
|
|
744
699
|
});
|
|
745
700
|
|
|
701
|
+
// HOOK 1a: END-OF-QUARTER SPIKE — days 100-110, billing events flip
|
|
702
|
+
// event_type to plan_upgraded 40% of the time. No flag.
|
|
703
|
+
userEvents.forEach(e => {
|
|
704
|
+
if (e.event !== "billing event") return;
|
|
705
|
+
const dayInDataset = dayjs(e.time).diff(datasetStart, "days", true);
|
|
706
|
+
if (dayInDataset >= 100 && dayInDataset <= 110 && chance.bool({ likelihood: 40 })) {
|
|
707
|
+
e.event_type = "plan_upgraded";
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// HOOK 1b: END-OF-QUARTER TEAM INVITE SPIKE — days 100-110, clone
|
|
712
|
+
// 50% of team-member-invited events (push, not return). No flag.
|
|
713
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
714
|
+
const e = userEvents[i];
|
|
715
|
+
if (e.event !== "team member invited") continue;
|
|
716
|
+
const dayInDataset = dayjs(e.time).diff(datasetStart, "days", true);
|
|
717
|
+
if (dayInDataset >= 100 && dayInDataset <= 110 && chance.bool({ likelihood: 50 })) {
|
|
718
|
+
userEvents.push({
|
|
719
|
+
...e,
|
|
720
|
+
time: dayjs(e.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
721
|
+
user_id: e.user_id,
|
|
722
|
+
role: chance.pickone(["editor", "viewer"]),
|
|
723
|
+
invitation_method: chance.pickone(["email", "sso", "slack"]),
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
746
728
|
// HOOK 2: CHURNED ACCOUNT SILENCING — ~20% of users (hash %5)
|
|
747
729
|
// have post-day-30 events removed. No flag.
|
|
748
730
|
if (userEvents && userEvents.length > 0) {
|
|
@@ -780,10 +762,10 @@ const config = {
|
|
|
780
762
|
});
|
|
781
763
|
}
|
|
782
764
|
|
|
783
|
-
// HOOK 5 + HOOK 10: DOCS MAGIC NUMBER (
|
|
765
|
+
// HOOK 5 + HOOK 10: DOCS MAGIC NUMBER (no flags)
|
|
784
766
|
// Sweet 4-7 documentation-viewed events → +40% extra cloned
|
|
785
767
|
// service-deployed events. Over 8+ → drop 25% of service-deployed
|
|
786
|
-
// events
|
|
768
|
+
// events. No flag.
|
|
787
769
|
const docsCount = userEvents.filter(e => e.event === "documentation viewed").length;
|
|
788
770
|
const deployTemplate = userEvents.find(e => e.event === "service deployed");
|
|
789
771
|
if (docsCount >= 4 && docsCount <= 7 && deployTemplate) {
|
|
@@ -807,6 +789,40 @@ const config = {
|
|
|
807
789
|
}
|
|
808
790
|
}
|
|
809
791
|
}
|
|
792
|
+
|
|
793
|
+
// HOOK 7: FAILED DEPLOYMENT RECOVERY — find failed→success pairs
|
|
794
|
+
// in this user's pipeline events, multiply duration_sec by 1.5 on
|
|
795
|
+
// the recovery deploy. Full control via everything hook.
|
|
796
|
+
const pipelineEvents = userEvents
|
|
797
|
+
.filter(e => e.event === "deployment pipeline run")
|
|
798
|
+
.sort((a, b) => a.time.localeCompare(b.time));
|
|
799
|
+
for (let i = 1; i < pipelineEvents.length; i++) {
|
|
800
|
+
if (pipelineEvents[i - 1].status === "failed" && pipelineEvents[i].status === "success") {
|
|
801
|
+
pipelineEvents[i].duration_sec = Math.floor((pipelineEvents[i].duration_sec || 300) * 1.5);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// HOOK 9: INCIDENT RESPONSE TTC — enterprise resolves faster,
|
|
806
|
+
// startup resolves slower. Scale the time gap between alert
|
|
807
|
+
// triggered → acknowledged → resolved for this user.
|
|
808
|
+
const segment = profile?.company_size;
|
|
809
|
+
const ttcFactor = (
|
|
810
|
+
segment === "enterprise" ? 0.5 :
|
|
811
|
+
segment === "startup" ? 1.8 :
|
|
812
|
+
1.0
|
|
813
|
+
);
|
|
814
|
+
if (ttcFactor !== 1.0) {
|
|
815
|
+
const alertEvents = userEvents
|
|
816
|
+
.filter(e => e.event === "alert triggered" || e.event === "alert acknowledged" || e.event === "alert resolved")
|
|
817
|
+
.sort((a, b) => a.time.localeCompare(b.time));
|
|
818
|
+
for (let i = 1; i < alertEvents.length; i++) {
|
|
819
|
+
const prev = dayjs(alertEvents[i - 1].time);
|
|
820
|
+
const curr = dayjs(alertEvents[i].time);
|
|
821
|
+
const gap = curr.diff(prev);
|
|
822
|
+
const newGap = Math.round(gap * ttcFactor);
|
|
823
|
+
alertEvents[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
810
826
|
}
|
|
811
827
|
|
|
812
828
|
// HOOK 8: ENTERPRISE VS STARTUP (user) — company size determines
|