@ak--47/dungeon-master 1.1.0 → 1.2.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/dungeons/technical/ad-spend-schema.json +2 -2
- package/dungeons/technical/ad-spend.js +16 -5
- package/dungeons/technical/anonymous-users-schema.json +4 -3
- package/dungeons/technical/anonymous-users.js +16 -6
- package/dungeons/technical/array-of-object-lookup-schema.json +1 -1
- package/dungeons/technical/array-of-object-lookup.js +22 -6
- package/dungeons/technical/experiments-schema.json +25 -19
- package/dungeons/technical/experiments.js +24 -6
- package/dungeons/technical/foobar.js +29 -13
- package/dungeons/technical/group-analytics-schema.json +92 -0
- package/dungeons/technical/group-analytics.js +16 -6
- package/dungeons/technical/mirror-strategies-schema.json +4 -4
- package/dungeons/technical/mirror-strategies.js +16 -6
- package/dungeons/technical/nested-objects.js +16 -5
- package/dungeons/technical/retention-cadence.js +14 -9
- package/dungeons/technical/sanity-schema.json +22 -22
- package/dungeons/technical/sanity.js +26 -6
- package/dungeons/technical/scale-test.js +16 -5
- package/dungeons/technical/scd-schema.json +230 -33
- package/dungeons/technical/scd.js +25 -5
- package/dungeons/technical/simple-schema.json +132 -48
- package/dungeons/technical/simple.js +21 -6
- package/dungeons/technical/simplest-schema.json +168 -79
- package/dungeons/technical/simplest.js +24 -7
- package/dungeons/technical/text-generation-schema.json +7 -4
- package/dungeons/technical/text-generation.js +23 -8
- package/dungeons/user/.gitkeep +0 -0
- package/dungeons/user/shalini.js +847 -0
- package/dungeons/vertical/community-schema.json +71 -49
- package/dungeons/vertical/community.js +57 -30
- package/dungeons/vertical/devtools-schema.json +69 -48
- package/dungeons/vertical/devtools.js +58 -31
- package/dungeons/vertical/ecommerce-schema.json +126 -108
- package/dungeons/vertical/ecommerce.js +32 -8
- package/dungeons/vertical/education-schema.json +5261 -272
- package/dungeons/vertical/education.js +45 -22
- package/dungeons/vertical/fintech-schema.json +165 -98
- package/dungeons/vertical/fintech.js +43 -11
- package/dungeons/vertical/fitness-schema.json +69 -47
- package/dungeons/vertical/fitness.js +56 -24
- package/dungeons/vertical/food-delivery-schema.json +36237 -187
- package/dungeons/vertical/food-delivery.js +67 -15
- package/dungeons/vertical/gaming-schema.json +104 -60
- package/dungeons/vertical/gaming.js +61 -16
- package/dungeons/vertical/healthcare-schema.json +70 -48
- package/dungeons/vertical/healthcare.js +77 -42
- package/dungeons/vertical/insurance-application-schema.json +66 -47
- package/dungeons/vertical/insurance-application.js +34 -10
- package/dungeons/vertical/logistics-schema.json +60 -38
- package/dungeons/vertical/logistics.js +77 -43
- package/dungeons/vertical/marketplace-schema.json +65 -43
- package/dungeons/vertical/marketplace.js +52 -21
- package/dungeons/vertical/media-schema.json +4085 -123
- package/dungeons/vertical/media.js +65 -18
- package/dungeons/vertical/rpg-schema.json +1984 -147
- package/dungeons/vertical/rpg.js +47 -11
- package/dungeons/vertical/sass-schema.json +2622 -147
- package/dungeons/vertical/sass.js +40 -10
- package/dungeons/vertical/social-schema.json +146 -78
- package/dungeons/vertical/social.js +39 -10
- package/dungeons/vertical/travel-schema.json +57 -35
- package/dungeons/vertical/travel.js +58 -21
- package/index.js +4 -1
- package/lib/core/config-validator.js +13 -2
- package/lib/core/context.js +9 -0
- package/lib/core/storage.js +2 -0
- package/lib/generators/events.js +3 -5
- package/lib/orchestrators/user-loop.js +24 -1
- package/lib/utils/utils.js +129 -9
- package/package.json +1 -1
- package/scripts/extract-dungeon-schema.mjs +64 -18
- package/types.d.ts +8 -3
|
@@ -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(
|
|
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 (
|
|
174
|
+
* 8. REPEAT DESTINATION CLUSTERING (everything hook — event filtering)
|
|
168
175
|
* ───────────────────────────────────────────────────────────────
|
|
169
|
-
* PATTERN:
|
|
170
|
-
*
|
|
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:
|
|
203
|
-
numEvents: num_users *
|
|
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,
|
|
@@ -217,10 +224,16 @@ const config = {
|
|
|
217
224
|
hasAdSpend: false,
|
|
218
225
|
percentUsersBornInDataset: 35,
|
|
219
226
|
hasAvatar: true,
|
|
220
|
-
batchSize: 2_500_000,
|
|
221
227
|
concurrency: 1,
|
|
222
228
|
writeToDisk: false,
|
|
223
|
-
scdProps: {
|
|
229
|
+
scdProps: {
|
|
230
|
+
membership_tier: {
|
|
231
|
+
values: ["member", "silver", "gold", "platinum"],
|
|
232
|
+
frequency: "month",
|
|
233
|
+
timing: "fuzzy",
|
|
234
|
+
max: 8
|
|
235
|
+
}
|
|
236
|
+
},
|
|
224
237
|
mirrorProps: {},
|
|
225
238
|
lookupTables: [],
|
|
226
239
|
|
|
@@ -439,7 +452,7 @@ const config = {
|
|
|
439
452
|
|
|
440
453
|
// ── SuperProps ──────────────────────────────────────────
|
|
441
454
|
superProps: {
|
|
442
|
-
|
|
455
|
+
Platform: ["ios", "android", "web", "web"],
|
|
443
456
|
membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
|
|
444
457
|
},
|
|
445
458
|
|
|
@@ -450,6 +463,8 @@ const config = {
|
|
|
450
463
|
company_name: ["none"],
|
|
451
464
|
preferred_destination: chance.pickone.bind(chance, destinationCities),
|
|
452
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"],
|
|
453
468
|
},
|
|
454
469
|
|
|
455
470
|
// ── Personas ──────────────────────────────────
|
|
@@ -626,14 +641,10 @@ const config = {
|
|
|
626
641
|
}
|
|
627
642
|
|
|
628
643
|
// ── HOOK 8: REPEAT DESTINATION CLUSTERING (funnel-pre) ─
|
|
644
|
+
// conversionRate modifications moved to everything hook (event filtering)
|
|
645
|
+
// to avoid dilution by organic events
|
|
629
646
|
if (type === "funnel-pre") {
|
|
630
|
-
|
|
631
|
-
if (meta.profile.customer_segment === "business_traveler") {
|
|
632
|
-
record.conversionRate = Math.min(record.conversionRate * 1.3, 90);
|
|
633
|
-
} else if (meta.profile.customer_segment === "luxury_seeker") {
|
|
634
|
-
record.conversionRate = Math.min(record.conversionRate * 1.15, 85);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
647
|
+
// prop-setting only; no conversionRate changes
|
|
637
648
|
}
|
|
638
649
|
|
|
639
650
|
// ── HOOK 1: WEEKEND LEISURE SURGE (event) ────────────
|
|
@@ -666,6 +677,33 @@ const config = {
|
|
|
666
677
|
const events = record;
|
|
667
678
|
if (!events.length) return record;
|
|
668
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
|
+
|
|
669
707
|
// ── HOOK 3: LOYALTY TIER UPGRADE PATH ────────────
|
|
670
708
|
let bookingCount = 0;
|
|
671
709
|
events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
|
|
@@ -688,7 +726,6 @@ const config = {
|
|
|
688
726
|
}
|
|
689
727
|
|
|
690
728
|
// ── HOOK 5: UPSELL SUCCESS BY SEGMENT ────────────
|
|
691
|
-
const profile = meta.profile;
|
|
692
729
|
if (profile && profile.customer_segment === "luxury_seeker") {
|
|
693
730
|
const templateUpgrade = events.find(e => e.event === "room upgrade selected");
|
|
694
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
|
|
|
@@ -216,7 +219,7 @@ async function runDungeon(config) {
|
|
|
216
219
|
files: await extractFileInfo(storage, validatedConfig),
|
|
217
220
|
time: { start, end, delta, human },
|
|
218
221
|
operations: context.getOperations(),
|
|
219
|
-
eventCount: context.
|
|
222
|
+
eventCount: context.getStoredEventCount(),
|
|
220
223
|
userCount: context.getUserCount()
|
|
221
224
|
};
|
|
222
225
|
|
|
@@ -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
|
|
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,
|
package/lib/core/context.js
CHANGED
|
@@ -71,6 +71,7 @@ function createRuntimeState() {
|
|
|
71
71
|
return {
|
|
72
72
|
operations: 0,
|
|
73
73
|
eventCount: 0,
|
|
74
|
+
storedEventCount: 0,
|
|
74
75
|
userCount: 0,
|
|
75
76
|
isBatchMode: false,
|
|
76
77
|
verbose: false
|
|
@@ -143,6 +144,14 @@ export function createContext(config, storage = null, timeConstants = {}) {
|
|
|
143
144
|
runtime.eventCount++;
|
|
144
145
|
},
|
|
145
146
|
|
|
147
|
+
incrementStoredEvents(count = 1) {
|
|
148
|
+
runtime.storedEventCount += count;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
getStoredEventCount() {
|
|
152
|
+
return runtime.storedEventCount;
|
|
153
|
+
},
|
|
154
|
+
|
|
146
155
|
isBatchMode() {
|
|
147
156
|
return runtime.isBatchMode;
|
|
148
157
|
},
|
package/lib/core/storage.js
CHANGED
|
@@ -99,8 +99,10 @@ export async function createHookArray(arr = [], opts) {
|
|
|
99
99
|
// Fast path for passthrough hooks - no transformation needed
|
|
100
100
|
if (Array.isArray(item)) {
|
|
101
101
|
arr.push(...item);
|
|
102
|
+
if (type === "event" && context.incrementStoredEvents) context.incrementStoredEvents(item.length);
|
|
102
103
|
} else {
|
|
103
104
|
arr.push(item);
|
|
105
|
+
if (type === "event" && context.incrementStoredEvents) context.incrementStoredEvents(1);
|
|
104
106
|
}
|
|
105
107
|
} else {
|
|
106
108
|
// Slow path for actual transformation hooks
|
package/lib/generators/events.js
CHANGED
|
@@ -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
|
|
119
|
+
// Add anonymous identifiers
|
|
120
120
|
if (anonymousIds.length) {
|
|
121
121
|
eventTemplate.device_id = u.pickRandom(anonymousIds);
|
|
122
122
|
}
|
|
123
|
-
|
|
124
|
-
|
|
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 })) {
|
|
@@ -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) {
|
|
@@ -397,7 +399,10 @@ export async function userLoop(context) {
|
|
|
397
399
|
// Subscription events (trial, upgrade, cancel) must not be randomly dropped by decay
|
|
398
400
|
const userDecay = persona?.engagementDecay || globalEngagementDecay;
|
|
399
401
|
if (userDecay && userDecay.model !== 'none' && usersEvents.length > 0) {
|
|
400
|
-
|
|
402
|
+
// adjustedCreated is in FIXED time space, but ev.time is in PRESENT time (shifted).
|
|
403
|
+
// Shift adjustedCreated to present so daysSinceBirth reflects within-dataset age.
|
|
404
|
+
const adjustedCreatedPresent = adjustedCreated.add(context.TIME_SHIFT_SECONDS, 'seconds');
|
|
405
|
+
usersEvents = applyEngagementDecay(usersEvents, userDecay, adjustedCreatedPresent, context, chance);
|
|
401
406
|
}
|
|
402
407
|
|
|
403
408
|
// Feature 5: Subscription lifecycle — inject after decay (exempt from decay filtering)
|
|
@@ -445,6 +450,24 @@ export async function userLoop(context) {
|
|
|
445
450
|
}
|
|
446
451
|
}
|
|
447
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
|
+
|
|
448
471
|
// Hook for processing all user events (hooks override everything)
|
|
449
472
|
if (config.hook) {
|
|
450
473
|
const newEvents = await config.hook(usersEvents, "everything", {
|
package/lib/utils/utils.js
CHANGED
|
@@ -1314,15 +1314,7 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
|
|
|
1314
1314
|
|
|
1315
1315
|
if (!hasAnonIds) delete user.anonymousIds;
|
|
1316
1316
|
|
|
1317
|
-
//
|
|
1318
|
-
if (hasSessionIds) {
|
|
1319
|
-
const sessionSize = integer(5, 30);
|
|
1320
|
-
for (let i = 0; i < sessionSize; i++) {
|
|
1321
|
-
const sessionId = [uid(5), uid(5), uid(5), uid(5)].join("-");
|
|
1322
|
-
user.sessionIds.push(sessionId);
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1317
|
+
// Session IDs are now assigned post-hoc in user-loop.js based on temporal gaps
|
|
1326
1318
|
if (!hasSessionIds) delete user.sessionIds;
|
|
1327
1319
|
|
|
1328
1320
|
return user;
|
|
@@ -1436,6 +1428,131 @@ function deepClone(thing, opts) {
|
|
|
1436
1428
|
};
|
|
1437
1429
|
|
|
1438
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* Generates a session ID in the standard format
|
|
1433
|
+
* @returns {string} Session ID like "xxxxx-xxxxx-xxxxx-xxxxx"
|
|
1434
|
+
*/
|
|
1435
|
+
function generateSessionId() {
|
|
1436
|
+
return [uid(5), uid(5), uid(5), uid(5)].join("-");
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Redistributes events into temporal clusters (sessions).
|
|
1441
|
+
*
|
|
1442
|
+
* Algorithm:
|
|
1443
|
+
* 1. Sort events by time
|
|
1444
|
+
* 2. Determine number of sessions (total events / avg events per session)
|
|
1445
|
+
* 3. Generate session anchor times using TimeSoup
|
|
1446
|
+
* 4. Assign events round-robin to sessions
|
|
1447
|
+
* 5. Within each session, retime events with tight spacing (5-300s apart)
|
|
1448
|
+
* 6. Regenerate insert_ids for retimed events
|
|
1449
|
+
* 7. Re-sort by time
|
|
1450
|
+
*
|
|
1451
|
+
* Mutates events in place. Does NOT assign session_id (call assignSessionIds after).
|
|
1452
|
+
*
|
|
1453
|
+
* @param {Object[]} events - Array of event objects with .time (ISO string)
|
|
1454
|
+
* @param {number} timeoutMinutes - Session timeout in minutes (used to determine intra-session spacing)
|
|
1455
|
+
* @param {Object} soupParams - Parameters for TimeSoup anchor generation
|
|
1456
|
+
*/
|
|
1457
|
+
function bunchIntoSessions(events, timeoutMinutes, soupParams) {
|
|
1458
|
+
if (events.length < 2) return;
|
|
1459
|
+
|
|
1460
|
+
const chance = getChance();
|
|
1461
|
+
const { earliestTime, latestTime, peaks, deviation, mean,
|
|
1462
|
+
dayOfWeekWeights, hourOfDayWeights, timeShiftSeconds, maxTime } = soupParams;
|
|
1463
|
+
|
|
1464
|
+
// Sort by time first
|
|
1465
|
+
events.sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
|
|
1466
|
+
|
|
1467
|
+
// Determine number of sessions: target 3-8 events per session
|
|
1468
|
+
const eventsPerSession = chance.integer({ min: 3, max: 8 });
|
|
1469
|
+
const numSessions = Math.max(1, Math.ceil(events.length / eventsPerSession));
|
|
1470
|
+
|
|
1471
|
+
// Generate session anchor times using TimeSoup
|
|
1472
|
+
const anchors = [];
|
|
1473
|
+
for (let i = 0; i < numSessions; i++) {
|
|
1474
|
+
const soupTime = TimeSoup(earliestTime, latestTime, peaks, deviation, mean,
|
|
1475
|
+
dayOfWeekWeights, hourOfDayWeights, timeShiftSeconds);
|
|
1476
|
+
anchors.push(soupTime + timeShiftSeconds); // shifted to present time
|
|
1477
|
+
}
|
|
1478
|
+
anchors.sort((a, b) => a - b);
|
|
1479
|
+
|
|
1480
|
+
// Distribute events across sessions round-robin (preserving original order → temporal order)
|
|
1481
|
+
const sessionBuckets = anchors.map(() => []);
|
|
1482
|
+
for (let i = 0; i < events.length; i++) {
|
|
1483
|
+
const bucketIndex = Math.min(i % numSessions, numSessions - 1);
|
|
1484
|
+
sessionBuckets[bucketIndex].push(events[i]);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Retime events within each session
|
|
1488
|
+
let writeIndex = 0;
|
|
1489
|
+
for (let s = 0; s < numSessions; s++) {
|
|
1490
|
+
const bucket = sessionBuckets[s];
|
|
1491
|
+
if (bucket.length === 0) continue;
|
|
1492
|
+
|
|
1493
|
+
let currentTime = anchors[s];
|
|
1494
|
+
for (let e = 0; e < bucket.length; e++) {
|
|
1495
|
+
const ev = bucket[e];
|
|
1496
|
+
const clampedTime = Math.min(currentTime, maxTime);
|
|
1497
|
+
|
|
1498
|
+
ev.time = dayjs.unix(clampedTime).toISOString();
|
|
1499
|
+
// Regenerate insert_id to match new time
|
|
1500
|
+
const distinctId = ev.user_id || ev.device_id || ev.distinct_id || '';
|
|
1501
|
+
ev.insert_id = quickHash(`${ev.event}-${ev.time}-${distinctId}`);
|
|
1502
|
+
|
|
1503
|
+
if (currentTime > maxTime) {
|
|
1504
|
+
ev._drop = true;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// Advance time within session: 5-300 seconds (5s to 5min)
|
|
1508
|
+
currentTime += chance.integer({ min: 5, max: 300 });
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Re-sort by time
|
|
1513
|
+
events.sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Assigns session IDs to a chronologically sorted array of events.
|
|
1518
|
+
* A new session starts when:
|
|
1519
|
+
* - Gap between consecutive events exceeds timeoutMinutes
|
|
1520
|
+
* - Session duration exceeds 24 hours
|
|
1521
|
+
*
|
|
1522
|
+
* Events MUST be sorted by time before calling this function.
|
|
1523
|
+
* Mutates events in place (adds session_id property).
|
|
1524
|
+
*
|
|
1525
|
+
* @param {Object[]} events - Sorted array of event objects with .time (ISO string)
|
|
1526
|
+
* @param {number} timeoutMinutes - Session timeout in minutes (default 30)
|
|
1527
|
+
* @returns {Object[]} Same array, with session_id added to each event
|
|
1528
|
+
*/
|
|
1529
|
+
function assignSessionIds(events, timeoutMinutes = 30) {
|
|
1530
|
+
if (!events.length) return events;
|
|
1531
|
+
|
|
1532
|
+
const timeoutMs = timeoutMinutes * 60 * 1000;
|
|
1533
|
+
const maxSessionMs = 24 * 60 * 60 * 1000;
|
|
1534
|
+
|
|
1535
|
+
let currentSessionId = generateSessionId();
|
|
1536
|
+
let sessionStartMs = new Date(events[0].time).getTime();
|
|
1537
|
+
let lastEventMs = sessionStartMs;
|
|
1538
|
+
|
|
1539
|
+
for (const event of events) {
|
|
1540
|
+
const eventMs = new Date(event.time).getTime();
|
|
1541
|
+
const gapFromLast = eventMs - lastEventMs;
|
|
1542
|
+
const sessionDuration = eventMs - sessionStartMs;
|
|
1543
|
+
|
|
1544
|
+
if (gapFromLast > timeoutMs || sessionDuration > maxSessionMs) {
|
|
1545
|
+
currentSessionId = generateSessionId();
|
|
1546
|
+
sessionStartMs = eventMs;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
event.session_id = currentSessionId;
|
|
1550
|
+
lastEventMs = eventMs;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return events;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1439
1556
|
export {
|
|
1440
1557
|
pick,
|
|
1441
1558
|
date,
|
|
@@ -1486,4 +1603,7 @@ export {
|
|
|
1486
1603
|
wrapFunc,
|
|
1487
1604
|
bytesHuman,
|
|
1488
1605
|
formatDuration,
|
|
1606
|
+
generateSessionId,
|
|
1607
|
+
assignSessionIds,
|
|
1608
|
+
bunchIntoSessions,
|
|
1489
1609
|
};
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Extracts
|
|
4
|
+
* Extracts a complete schema from a JavaScript dungeon file.
|
|
5
5
|
*
|
|
6
|
-
* Output contains
|
|
7
|
-
*
|
|
6
|
+
* Output contains everything about the dungeon EXCEPT:
|
|
7
|
+
* - hook function (runtime logic, not schema)
|
|
8
|
+
* - credentials (token, serviceAccount, serviceSecret, projectId)
|
|
9
|
+
* - execution params (seed, format, gzip, verbose, concurrency, batchSize, writeToDisk, region)
|
|
8
10
|
*
|
|
9
11
|
* Property values are simplified:
|
|
10
12
|
* - Plain arrays → deduplicated
|
|
11
13
|
* - weighNumRange(min,max) arrays → { "$range": [min, max] }
|
|
12
14
|
* - Functions (pickAWinner, weighChoices, etc.) → sampled and deduplicated
|
|
13
|
-
* - High-cardinality / complex values →
|
|
15
|
+
* - High-cardinality / complex values → type hint
|
|
14
16
|
*
|
|
15
17
|
* Usage:
|
|
16
18
|
* node scripts/extract-dungeon-schema.mjs <input.js> [output.json]
|
|
@@ -61,12 +63,16 @@ async function processOne(inputFile, outputFile) {
|
|
|
61
63
|
|
|
62
64
|
const schema = {};
|
|
63
65
|
|
|
64
|
-
// Events
|
|
66
|
+
// ── Events ──────────────────────────────────────────────────────
|
|
65
67
|
if (config.events?.length) {
|
|
66
68
|
schema.events = config.events.map(evt => {
|
|
67
69
|
const entry = { event: evt.event };
|
|
68
70
|
if (evt.weight && evt.weight !== 1) entry.weight = evt.weight;
|
|
69
71
|
if (evt.isFirstEvent) entry.isFirstEvent = true;
|
|
72
|
+
if (evt.isChurnEvent) entry.isChurnEvent = true;
|
|
73
|
+
if (evt.returnLikelihood !== undefined) entry.returnLikelihood = evt.returnLikelihood;
|
|
74
|
+
if (evt.isStrictEvent) entry.isStrictEvent = true;
|
|
75
|
+
if (evt.isSessionStartEvent) entry.isSessionStartEvent = true;
|
|
70
76
|
if (evt.properties && Object.keys(evt.properties).length) {
|
|
71
77
|
const props = extractProps(evt.properties);
|
|
72
78
|
if (Object.keys(props).length) entry.properties = props;
|
|
@@ -75,22 +81,11 @@ async function processOne(inputFile, outputFile) {
|
|
|
75
81
|
});
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
//
|
|
79
|
-
if (config.superProps && Object.keys(config.superProps).length) {
|
|
80
|
-
const sp = extractProps(config.superProps);
|
|
81
|
-
if (Object.keys(sp).length) schema.superProps = sp;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// User props
|
|
85
|
-
if (config.userProps && Object.keys(config.userProps).length) {
|
|
86
|
-
const up = extractProps(config.userProps);
|
|
87
|
-
if (Object.keys(up).length) schema.userProps = up;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Funnels
|
|
84
|
+
// ── Funnels ─────────────────────────────────────────────────────
|
|
91
85
|
if (config.funnels?.length) {
|
|
92
86
|
schema.funnels = config.funnels.map(f => {
|
|
93
87
|
const entry = { sequence: f.sequence };
|
|
88
|
+
if (f.name) entry.name = f.name;
|
|
94
89
|
if (f.isFirstFunnel) entry.isFirstFunnel = true;
|
|
95
90
|
if (f.conversionRate !== undefined) entry.conversionRate = f.conversionRate;
|
|
96
91
|
if (f.timeToConvert !== undefined) entry.timeToConvert = f.timeToConvert;
|
|
@@ -98,6 +93,8 @@ async function processOne(inputFile, outputFile) {
|
|
|
98
93
|
if (f.weight && f.weight !== 1) entry.weight = f.weight;
|
|
99
94
|
if (f.requireRepeats) entry.requireRepeats = true;
|
|
100
95
|
if (f.experiment) entry.experiment = true;
|
|
96
|
+
if (f.bindPropsIndex !== undefined) entry.bindPropsIndex = f.bindPropsIndex;
|
|
97
|
+
if (f.conditions) entry.conditions = f.conditions;
|
|
101
98
|
if (f.props && Object.keys(f.props).length) {
|
|
102
99
|
const fp = extractProps(f.props);
|
|
103
100
|
if (Object.keys(fp).length) entry.props = fp;
|
|
@@ -106,6 +103,55 @@ async function processOne(inputFile, outputFile) {
|
|
|
106
103
|
});
|
|
107
104
|
}
|
|
108
105
|
|
|
106
|
+
// ── Super props ─────────────────────────────────────────────────
|
|
107
|
+
if (config.superProps && Object.keys(config.superProps).length) {
|
|
108
|
+
const sp = extractProps(config.superProps);
|
|
109
|
+
if (Object.keys(sp).length) schema.superProps = sp;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── User props ──────────────────────────────────────────────────
|
|
113
|
+
if (config.userProps && Object.keys(config.userProps).length) {
|
|
114
|
+
const up = extractProps(config.userProps);
|
|
115
|
+
if (Object.keys(up).length) schema.userProps = up;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Group keys ──────────────────────────────────────────────────
|
|
119
|
+
if (config.groupKeys?.length) {
|
|
120
|
+
schema.groupKeys = config.groupKeys.map(gk => {
|
|
121
|
+
if (Array.isArray(gk)) {
|
|
122
|
+
const entry = { key: gk[0], count: gk[1] };
|
|
123
|
+
if (gk[2]) entry.affectsEvents = gk[2];
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
return gk;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Group props ─────────────────────────────────────────────────
|
|
131
|
+
if (config.groupProps && Object.keys(config.groupProps).length) {
|
|
132
|
+
schema.groupProps = {};
|
|
133
|
+
for (const [groupKey, props] of Object.entries(config.groupProps)) {
|
|
134
|
+
schema.groupProps[groupKey] = extractProps(props);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── SCD props ───────────────────────────────────────────────────
|
|
139
|
+
if (config.scdProps && Object.keys(config.scdProps).length) {
|
|
140
|
+
schema.scdProps = {};
|
|
141
|
+
for (const [key, scd] of Object.entries(config.scdProps)) {
|
|
142
|
+
const entry = {};
|
|
143
|
+
entry.type = scd.type || "user";
|
|
144
|
+
if (scd.frequency) entry.frequency = scd.frequency;
|
|
145
|
+
if (scd.timing) entry.timing = scd.timing;
|
|
146
|
+
if (scd.max !== undefined) entry.max = scd.max;
|
|
147
|
+
if (scd.values) {
|
|
148
|
+
const simplified = simplifyValue(scd.values);
|
|
149
|
+
if (simplified) entry.values = simplified;
|
|
150
|
+
}
|
|
151
|
+
schema.scdProps[key] = entry;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
109
155
|
writeFileSync(outputPath, JSON.stringify(schema, null, 2) + '\n', 'utf-8');
|
|
110
156
|
console.log(` ✅ ${baseName}-schema.json`);
|
|
111
157
|
}
|
package/types.d.ts
CHANGED
|
@@ -72,8 +72,10 @@ export interface Dungeon {
|
|
|
72
72
|
verbose?: boolean;
|
|
73
73
|
/** If true, users get anonymous device IDs in addition to distinct_id. */
|
|
74
74
|
hasAnonIds?: boolean;
|
|
75
|
-
/** If true, users get session IDs attached to events. */
|
|
75
|
+
/** If true, users get session IDs attached to events based on temporal clustering. */
|
|
76
76
|
hasSessionIds?: boolean;
|
|
77
|
+
/** Session timeout in minutes. Events with gaps exceeding this start a new session. Default: 30. Only used when hasSessionIds is true. */
|
|
78
|
+
sessionTimeout?: number;
|
|
77
79
|
/** If true, auto-generates funnels from the events array in addition to any explicit funnels. */
|
|
78
80
|
alsoInferFunnels?: boolean;
|
|
79
81
|
/** Restrict all location data to a single country (e.g., "US", "GB"). */
|
|
@@ -231,8 +233,8 @@ export interface hookArrayOptions<T> {
|
|
|
231
233
|
* an enriched array is an array that has a hookPush method that can be used to transform-then-push items into the array
|
|
232
234
|
*/
|
|
233
235
|
export interface HookedArray<T> extends Array<T> {
|
|
234
|
-
hookPush: (item: T | T[], ...meta: any[]) => any
|
|
235
|
-
flush: () => void
|
|
236
|
+
hookPush: (item: T | T[], ...meta: any[]) => Promise<any>;
|
|
237
|
+
flush: () => Promise<void>;
|
|
236
238
|
getWriteDir: () => string;
|
|
237
239
|
getWritePath: () => string;
|
|
238
240
|
[key: string]: any;
|
|
@@ -266,6 +268,7 @@ export interface Storage {
|
|
|
266
268
|
export interface RuntimeState {
|
|
267
269
|
operations: number;
|
|
268
270
|
eventCount: number;
|
|
271
|
+
storedEventCount: number;
|
|
269
272
|
userCount: number;
|
|
270
273
|
isBatchMode: boolean;
|
|
271
274
|
verbose: boolean;
|
|
@@ -305,11 +308,13 @@ export interface Context {
|
|
|
305
308
|
incrementOperations(): void;
|
|
306
309
|
incrementEvents(): void;
|
|
307
310
|
incrementUsers(): void;
|
|
311
|
+
incrementStoredEvents(count?: number): void;
|
|
308
312
|
setStorage(storage: Storage): void;
|
|
309
313
|
|
|
310
314
|
// State getter methods
|
|
311
315
|
getOperations(): number;
|
|
312
316
|
getEventCount(): number;
|
|
317
|
+
getStoredEventCount(): number;
|
|
313
318
|
getUserCount(): number;
|
|
314
319
|
incrementUserCount(): void;
|
|
315
320
|
incrementEventCount(): void;
|