@ak--47/dungeon-master 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/dungeons/technical/array-of-object-lookup.js +0 -2
- 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/technical/simple.js +3 -4
- package/dungeons/technical/simplest-schema.json +5 -0
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +216 -164
- package/dungeons/vertical/community.js +74 -41
- package/dungeons/vertical/crypto.js +343 -233
- package/dungeons/vertical/dating.js +246 -292
- package/dungeons/vertical/devtools.js +179 -70
- package/dungeons/vertical/ecommerce.js +275 -122
- package/dungeons/vertical/education.js +345 -312
- package/dungeons/vertical/fintech.js +445 -314
- package/dungeons/vertical/fitness.js +199 -165
- package/dungeons/vertical/food-delivery.js +309 -330
- package/dungeons/vertical/gaming.js +387 -241
- package/dungeons/vertical/healthcare.js +149 -66
- package/dungeons/vertical/insurance-application.js +173 -75
- package/dungeons/vertical/logistics.js +107 -12
- package/dungeons/vertical/marketplace.js +171 -76
- package/dungeons/vertical/media.js +259 -402
- package/dungeons/vertical/real-estate.js +336 -318
- package/dungeons/vertical/sass.js +294 -288
- package/dungeons/vertical/social.js +289 -234
- package/dungeons/vertical/travel.js +66 -20
- package/index.js +17 -17
- package/lib/core/config-validator.js +226 -175
- package/lib/core/context.js +10 -24
- package/lib/core/storage.js +6 -1
- package/lib/generators/events.js +57 -104
- package/lib/generators/funnels.js +212 -93
- package/lib/generators/mirror.js +3 -2
- package/lib/generators/product-names.js +1 -1
- package/lib/generators/scd.js +2 -1
- package/lib/generators/text.js +1 -1
- 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/user-loop.js +143 -293
- package/lib/utils/utils.js +97 -52
- 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/scripts/smoke-test-all.mjs +162 -0
- package/scripts/verify-runner.mjs +72 -24
- package/types.d.ts +434 -225
- package/dungeons/technical/ad-spend-schema.json +0 -128
- package/dungeons/technical/anonymous-users-schema.json +0 -92
- package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
- package/dungeons/technical/experiments-schema.json +0 -203
- package/dungeons/technical/foobar-schema.json +0 -362
- package/dungeons/technical/group-analytics-schema.json +0 -241
- package/dungeons/technical/mirror-strategies-schema.json +0 -84
- package/dungeons/technical/nested-objects-schema.json +0 -145
- package/dungeons/technical/retention-cadence-schema.json +0 -37
- package/dungeons/technical/sanity-schema.json +0 -185
- package/dungeons/technical/scale-test-schema.json +0 -70
- package/dungeons/technical/scd-schema.json +0 -467
- package/dungeons/technical/simple-schema.json +0 -362
- package/dungeons/technical/text-generation-schema.json +0 -1062
- package/dungeons/vertical/ai-platform-schema.json +0 -617
- package/dungeons/vertical/community-schema.json +0 -579
- package/dungeons/vertical/crypto-schema.json +0 -546
- package/dungeons/vertical/dating-schema.json +0 -401
- package/dungeons/vertical/devtools-schema.json +0 -601
- package/dungeons/vertical/ecommerce-schema.json +0 -604
- package/dungeons/vertical/education-schema.json +0 -5686
- package/dungeons/vertical/fintech-schema.json +0 -630
- package/dungeons/vertical/fitness-schema.json +0 -530
- package/dungeons/vertical/food-delivery-schema.json +0 -36728
- package/dungeons/vertical/gaming-schema.json +0 -2703
- package/dungeons/vertical/healthcare-schema.json +0 -549
- package/dungeons/vertical/insurance-application-schema.json +0 -485
- package/dungeons/vertical/logistics-schema.json +0 -574
- package/dungeons/vertical/marketplace-schema.json +0 -533
- package/dungeons/vertical/media-schema.json +0 -4749
- package/dungeons/vertical/real-estate-schema.json +0 -527
- package/dungeons/vertical/sass-schema.json +0 -3128
- package/dungeons/vertical/social-schema.json +0 -620
- package/dungeons/vertical/travel-schema.json +0 -580
package/lib/generators/events.js
CHANGED
|
@@ -10,25 +10,28 @@
|
|
|
10
10
|
/** @typedef {import('../../types').Context} Context */
|
|
11
11
|
|
|
12
12
|
import dayjs from "dayjs";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
13
14
|
import * as u from "../utils/utils.js";
|
|
14
15
|
import { dataLogger as logger } from "../utils/logger.js";
|
|
15
16
|
|
|
16
17
|
// Keys that must never be nulled by data quality gremlins
|
|
17
18
|
const NULL_EXEMPT_KEYS = new Set(['event', 'time', 'insert_id', 'user_id', 'device_id', 'distinct_id', '_drop', '_anomaly', '_persona']);
|
|
18
19
|
|
|
20
|
+
|
|
19
21
|
/**
|
|
20
22
|
* Creates a Mixpanel event with a flat shape
|
|
21
|
-
* @param {Context} context
|
|
22
|
-
* @param {string} distinct_id
|
|
23
|
+
* @param {Context} context
|
|
24
|
+
* @param {string} distinct_id
|
|
23
25
|
* @param {number} earliestTime - Unix timestamp for earliest possible event time
|
|
24
26
|
* @param {Object} chosenEvent - Event configuration object
|
|
25
27
|
* @param {string[]} [anonymousIds] - Array of anonymous/device IDs
|
|
26
|
-
* @param {string[]} [sessionIds] - Array of session IDs
|
|
27
28
|
* @param {Object} [superProps] - Super properties to add to event
|
|
28
29
|
* @param {Array} [groupKeys] - Group key configurations
|
|
29
|
-
* @param {boolean} [isFirstEvent]
|
|
30
|
-
* @param {boolean} [skipDefaults]
|
|
31
|
-
* @
|
|
30
|
+
* @param {boolean} [isFirstEvent]
|
|
31
|
+
* @param {boolean} [skipDefaults]
|
|
32
|
+
* @param {Object} [featureCtx] - Feature context (persona, worldEvents, dataQuality)
|
|
33
|
+
* @param {Object} [identityCtx] - Phase 2 identity context ({ stamping, devicePool })
|
|
34
|
+
* @returns {Promise<Object>}
|
|
32
35
|
*/
|
|
33
36
|
export async function makeEvent(
|
|
34
37
|
context,
|
|
@@ -36,12 +39,12 @@ export async function makeEvent(
|
|
|
36
39
|
earliestTime,
|
|
37
40
|
chosenEvent,
|
|
38
41
|
anonymousIds = [],
|
|
39
|
-
sessionIds = [],
|
|
40
42
|
superProps = {},
|
|
41
43
|
groupKeys = [],
|
|
42
44
|
isFirstEvent = false,
|
|
43
45
|
skipDefaults = false,
|
|
44
|
-
featureCtx = {}
|
|
46
|
+
featureCtx = {},
|
|
47
|
+
identityCtx = null
|
|
45
48
|
) {
|
|
46
49
|
// Validate required parameters
|
|
47
50
|
if (!distinct_id) throw new Error("no distinct_id");
|
|
@@ -89,9 +92,16 @@ export async function makeEvent(
|
|
|
89
92
|
defaultProps.browser = u.choose(defaults.browsers());
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
// Add campaigns with attribution likelihood
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
// Add campaigns with attribution likelihood.
|
|
96
|
+
// When any event has isAttributionEvent, only stamp UTMs on those events (25% chance).
|
|
97
|
+
// Otherwise, backwards-compat: ~25% of all events get UTMs.
|
|
98
|
+
if (hasCampaigns) {
|
|
99
|
+
const shouldStamp = config.hasAttributionFlags
|
|
100
|
+
? (chosenEvent.isAttributionEvent && chance.bool({ likelihood: 25 }))
|
|
101
|
+
: chance.bool({ likelihood: 25 });
|
|
102
|
+
if (shouldStamp) {
|
|
103
|
+
defaultProps.campaigns = u.pickRandom(defaults.campaigns());
|
|
104
|
+
}
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
// PERFORMANCE: Use pre-computed device pool instead of rebuilding every time
|
|
@@ -101,34 +111,41 @@ export async function makeEvent(
|
|
|
101
111
|
|
|
102
112
|
// Set event time using TimeSoup for realistic distribution
|
|
103
113
|
if (earliestTime) {
|
|
104
|
-
let
|
|
114
|
+
let unixTime;
|
|
105
115
|
if (isFirstEvent) {
|
|
106
|
-
|
|
116
|
+
unixTime = earliestTime;
|
|
107
117
|
} else {
|
|
108
|
-
|
|
109
|
-
const soupTimestamp = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights, context.TIME_SHIFT_SECONDS);
|
|
110
|
-
shiftedTimestamp = soupTimestamp + context.TIME_SHIFT_SECONDS;
|
|
118
|
+
unixTime = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
|
|
111
119
|
}
|
|
112
|
-
|
|
113
|
-
if (shiftedTimestamp > context.MAX_TIME) {
|
|
114
|
-
eventTemplate._drop = true;
|
|
115
|
-
}
|
|
116
|
-
eventTemplate.time = dayjs.unix(Math.min(shiftedTimestamp, context.MAX_TIME)).toISOString();
|
|
120
|
+
eventTemplate.time = dayjs.unix(unixTime).toISOString();
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
// ── Phase 2 identity stamping ──
|
|
124
|
+
// `identityCtx.stamping` modes:
|
|
125
|
+
// "both" → user_id + device_id (when pool non-empty). DEFAULT.
|
|
126
|
+
// "user_only" → user_id only (post-auth funnel events).
|
|
127
|
+
// "device_only" → device_id only (pre-auth funnel events).
|
|
128
|
+
// "stitch" → both (the one stitch event per converted born-in-dataset user).
|
|
129
|
+
// Callers (funnels.js / user-loop.js) compute the mode based on the user's auth state
|
|
130
|
+
// and the funnel's `isAuthEvent` placement. Default "both" preserves backwards-compat
|
|
131
|
+
// for dungeons that don't flag auth events. The legacy 42%-per-event user_id dice is
|
|
132
|
+
// gone — every event now gets user_id by default.
|
|
133
|
+
const stamping = (identityCtx && identityCtx.stamping) || 'both';
|
|
134
|
+
const wantsUser = stamping === 'both' || stamping === 'user_only' || stamping === 'stitch';
|
|
135
|
+
const wantsDevice = stamping === 'both' || stamping === 'device_only' || stamping === 'stitch';
|
|
136
|
+
const devicePool = (identityCtx && identityCtx.devicePool) || anonymousIds || [];
|
|
137
|
+
|
|
138
|
+
if (wantsDevice && devicePool && devicePool.length) {
|
|
139
|
+
eventTemplate.device_id = u.pickRandom(devicePool);
|
|
122
140
|
}
|
|
123
|
-
|
|
124
|
-
// Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
|
|
125
|
-
|
|
126
|
-
// Sometimes add user_id (for attribution modeling)
|
|
127
|
-
if (!isFirstEvent && chance.bool({ likelihood: 42 })) {
|
|
141
|
+
if (wantsUser) {
|
|
128
142
|
eventTemplate.user_id = distinct_id;
|
|
129
143
|
}
|
|
144
|
+
// Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
|
|
130
145
|
|
|
131
|
-
//
|
|
146
|
+
// Floor: every event must carry at least one of user_id / device_id (storage layer
|
|
147
|
+
// rejects events lacking both). If the stamping mode is "device_only" but there's no
|
|
148
|
+
// device pool, fall back to user_id rather than producing an invalid event.
|
|
132
149
|
if (!eventTemplate.user_id && !eventTemplate.device_id) {
|
|
133
150
|
eventTemplate.user_id = distinct_id;
|
|
134
151
|
}
|
|
@@ -171,36 +188,14 @@ export async function makeEvent(
|
|
|
171
188
|
addGroupProperties(eventTemplate, groupKeys);
|
|
172
189
|
|
|
173
190
|
// ── Event-level features (applied before hooks, so hooks can override) ──
|
|
174
|
-
const { userLocation, persona, worldEventsTimeline,
|
|
175
|
-
|
|
176
|
-
// Feature 6: Attribution — stamp UTM properties on events as touchpoints
|
|
177
|
-
// Mixpanel attribution analysis needs UTM on EVENTS, not just profiles.
|
|
178
|
-
// Pattern: first events carry acquisition UTM, later events occasionally carry re-engagement UTM.
|
|
179
|
-
if (userCampaign) {
|
|
180
|
-
// ~40% of events carry UTM (simulates page loads, session starts, ad clicks)
|
|
181
|
-
// First events (isFirstEvent) always carry UTM (acquisition touchpoint)
|
|
182
|
-
if (isFirstEvent || chance.bool({ likelihood: 40 })) {
|
|
183
|
-
eventTemplate.utm_source = userCampaign.source;
|
|
184
|
-
eventTemplate.utm_campaign = userCampaign.name;
|
|
185
|
-
if (userCampaign.medium) eventTemplate.utm_medium = userCampaign.medium;
|
|
186
|
-
if (userCampaign.utm_content) eventTemplate.utm_content = userCampaign.utm_content;
|
|
187
|
-
if (userCampaign.utm_term) eventTemplate.utm_term = userCampaign.utm_term;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Feature 7: Sticky geo location
|
|
192
|
-
if (userLocation && geoConfig?.sticky) {
|
|
193
|
-
// Override the random location with user's sticky location
|
|
194
|
-
for (const key in userLocation) {
|
|
195
|
-
eventTemplate[key] = userLocation[key];
|
|
196
|
-
}
|
|
197
|
-
}
|
|
191
|
+
const { userLocation, persona, worldEventsTimeline, dataQuality: dq } = featureCtx;
|
|
198
192
|
|
|
199
|
-
// Perf 1: Compute eventUnix once for all time-based checks
|
|
200
|
-
//
|
|
193
|
+
// Perf 1: Compute eventUnix once for all time-based checks. World events
|
|
194
|
+
// were resolved against the dataset window (no shift), and event times now
|
|
195
|
+
// also live in that same window — direct unix conversion works.
|
|
201
196
|
let eventUnix = null;
|
|
202
|
-
if (
|
|
203
|
-
eventUnix = dayjs(eventTemplate.time).
|
|
197
|
+
if (worldEventsTimeline && eventTemplate.time) {
|
|
198
|
+
eventUnix = dayjs(eventTemplate.time).unix();
|
|
204
199
|
}
|
|
205
200
|
|
|
206
201
|
// Feature 2: World events — inject props for active events at this timestamp
|
|
@@ -227,47 +222,6 @@ export async function makeEvent(
|
|
|
227
222
|
}
|
|
228
223
|
}
|
|
229
224
|
|
|
230
|
-
// Feature 8: Progressive feature adoption
|
|
231
|
-
if (resolvedFeatures && eventUnix !== null) {
|
|
232
|
-
const daysSinceBegin = (eventUnix - context.FIXED_BEGIN) / 86400;
|
|
233
|
-
for (const feat of resolvedFeatures) {
|
|
234
|
-
const affects = feat.affectsEvents;
|
|
235
|
-
if (affects !== "*" && !(Array.isArray(affects) && affects.includes(eventTemplate.event))) continue;
|
|
236
|
-
if (daysSinceBegin < feat.launchDay) {
|
|
237
|
-
if (feat.defaultBefore !== undefined) {
|
|
238
|
-
eventTemplate[feat.property] = feat.defaultBefore;
|
|
239
|
-
}
|
|
240
|
-
} else {
|
|
241
|
-
const daysSinceLaunch = daysSinceBegin - feat.launchDay;
|
|
242
|
-
const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
|
|
243
|
-
const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
|
|
244
|
-
if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
|
|
245
|
-
eventTemplate[feat.property] = u.pickRandom(feat._adoptedValues);
|
|
246
|
-
} else if (feat.defaultBefore !== undefined) {
|
|
247
|
-
eventTemplate[feat.property] = feat.defaultBefore;
|
|
248
|
-
} else if (feat.values.length > 0) {
|
|
249
|
-
eventTemplate[feat.property] = feat.values[0];
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Feature 9: Anomaly extreme values
|
|
256
|
-
if (resolvedAnomalies && eventTemplate.event) {
|
|
257
|
-
for (const a of resolvedAnomalies) {
|
|
258
|
-
if (a.type === 'extreme_value' && a.event === eventTemplate.event && a.property) {
|
|
259
|
-
if (chance.bool({ likelihood: (a.frequency ?? 0.001) * 100 })) {
|
|
260
|
-
const currentVal = eventTemplate[a.property];
|
|
261
|
-
if (typeof currentVal === 'number') {
|
|
262
|
-
eventTemplate[a.property] = currentVal * (a.multiplier || 10);
|
|
263
|
-
}
|
|
264
|
-
if (a.tag) eventTemplate._anomaly = a.tag;
|
|
265
|
-
if (a.properties) Object.assign(eventTemplate, a.properties);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
225
|
// Feature 4: Data quality — null injection and timezone confusion
|
|
272
226
|
if (dq) {
|
|
273
227
|
// Null injection
|
|
@@ -292,10 +246,7 @@ export async function makeEvent(
|
|
|
292
246
|
}
|
|
293
247
|
}
|
|
294
248
|
|
|
295
|
-
|
|
296
|
-
const distinctId = eventTemplate.user_id || eventTemplate.device_id || eventTemplate.distinct_id || distinct_id;
|
|
297
|
-
const tuple = `${eventTemplate.event}-${eventTemplate.time}-${distinctId}`;
|
|
298
|
-
eventTemplate.insert_id = u.quickHash(tuple);
|
|
249
|
+
eventTemplate.insert_id = randomUUID();
|
|
299
250
|
|
|
300
251
|
// Call hook if configured (hooks override everything — they are the final authority)
|
|
301
252
|
const { hook } = config;
|
|
@@ -303,7 +254,9 @@ export async function makeEvent(
|
|
|
303
254
|
const hookedEvent = await hook(eventTemplate, "event", {
|
|
304
255
|
user: { distinct_id },
|
|
305
256
|
config,
|
|
306
|
-
persona: featureCtx.persona || null
|
|
257
|
+
persona: featureCtx.persona || null,
|
|
258
|
+
datasetStart: context.DATASET_START_SECONDS,
|
|
259
|
+
datasetEnd: context.DATASET_END_SECONDS
|
|
307
260
|
});
|
|
308
261
|
// If hook returns a modified event, use it; otherwise use original
|
|
309
262
|
if (hookedEvent && typeof hookedEvent === 'object') {
|
|
@@ -19,9 +19,17 @@ import { dataLogger as logger } from "../utils/logger.js";
|
|
|
19
19
|
* @param {Object} profile - User profile object
|
|
20
20
|
* @param {Object} scd - Slowly changing dimensions object
|
|
21
21
|
* @param {Object} [persona] - User's assigned persona
|
|
22
|
-
* @
|
|
22
|
+
* @param {Object} [featureCtx] - Persona / world-event / data-quality / etc. context.
|
|
23
|
+
* @param {Object} [attemptMeta] - Phase 2 multi-attempt + identity context. Shape:
|
|
24
|
+
* `{ isFirstFunnel, isBorn, attemptsConfig, attemptNumber, totalAttempts, isFinalAttempt,
|
|
25
|
+
* truncateBeforeAuth, devicePool }`. When omitted defaults to a single normal attempt
|
|
26
|
+
* with no identity stamping overrides (legacy behavior).
|
|
27
|
+
* @returns {Promise<[Array, boolean, number|null]>} Tuple `[events, didConvert, authTimeMs]`
|
|
28
|
+
* where `authTimeMs` is the unix-millisecond timestamp of the stitch event (the first
|
|
29
|
+
* `isAuthEvent` step that actually fired in this funnel run), or null if the user did
|
|
30
|
+
* not reach the stitch step in this run.
|
|
23
31
|
*/
|
|
24
|
-
export async function makeFunnel(context, funnel, user, firstEventTime, profile = {}, scd = {}, persona = null, featureCtx = {}) {
|
|
32
|
+
export async function makeFunnel(context, funnel, user, firstEventTime, profile = {}, scd = {}, persona = null, featureCtx = {}, attemptMeta = null) {
|
|
25
33
|
if (!funnel) throw new Error("no funnel");
|
|
26
34
|
if (!user) throw new Error("no user");
|
|
27
35
|
|
|
@@ -29,48 +37,110 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
29
37
|
const chance = u.getChance();
|
|
30
38
|
const { hook = async (a) => a } = config;
|
|
31
39
|
|
|
40
|
+
// ── Phase 2 attempt + identity context ──
|
|
41
|
+
// Resolve a defensive default so legacy callers that don't pass attemptMeta still
|
|
42
|
+
// get a complete shape downstream (hook meta, identity stamping logic).
|
|
43
|
+
const meta = attemptMeta || {};
|
|
44
|
+
const attemptInfo = {
|
|
45
|
+
isFirstFunnel: !!meta.isFirstFunnel,
|
|
46
|
+
isBorn: meta.isBorn === undefined ? false : !!meta.isBorn,
|
|
47
|
+
attemptsConfig: meta.attemptsConfig || null,
|
|
48
|
+
attemptNumber: meta.attemptNumber || 1,
|
|
49
|
+
totalAttempts: meta.totalAttempts || 1,
|
|
50
|
+
isFinalAttempt: meta.isFinalAttempt === undefined ? true : !!meta.isFinalAttempt,
|
|
51
|
+
truncateBeforeAuth: !!meta.truncateBeforeAuth,
|
|
52
|
+
};
|
|
53
|
+
const devicePool = meta.devicePool || null;
|
|
54
|
+
|
|
32
55
|
// Get session start events if configured
|
|
33
56
|
const sessionStartEvents = config.events?.filter(a => a.isSessionStartEvent) || [];
|
|
34
57
|
|
|
35
58
|
// Clone funnel to avoid mutating the original object
|
|
36
59
|
funnel = { ...funnel };
|
|
37
60
|
|
|
38
|
-
// Experiment handling:
|
|
61
|
+
// Experiment handling: resolved by config-validator into funnel._experiment.
|
|
62
|
+
// Variant assignment is deterministic per-user (hash of userId + experiment name).
|
|
39
63
|
let experimentVariant = null;
|
|
40
64
|
let experimentName = null;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
let experimentMeta = null;
|
|
66
|
+
|
|
67
|
+
let expCfg = funnel._experiment;
|
|
68
|
+
if (!expCfg && funnel.experiment) {
|
|
69
|
+
const DEFAULT_VARIANTS = [
|
|
70
|
+
{ name: 'Variant A', conversionMultiplier: 0.7, ttcMultiplier: 1.5, weight: 1 },
|
|
71
|
+
{ name: 'Variant B', conversionMultiplier: 1.3, ttcMultiplier: 0.7, weight: 1 },
|
|
72
|
+
{ name: 'Control', conversionMultiplier: 1.0, ttcMultiplier: 1.0, weight: 1 },
|
|
73
|
+
];
|
|
74
|
+
expCfg = { name: (funnel.name ? funnel.name + ' Experiment' : 'Unnamed Experiment'), variants: DEFAULT_VARIANTS, startUnix: null };
|
|
75
|
+
}
|
|
76
|
+
if (expCfg) {
|
|
77
|
+
const isActive = !expCfg.startUnix || firstEventTime >= expCfg.startUnix;
|
|
78
|
+
if (isActive) {
|
|
79
|
+
experimentName = expCfg.name;
|
|
80
|
+
const userId = user.distinct_id || '';
|
|
81
|
+
const totalWeight = expCfg.variants.reduce((s, v) => s + v.weight, 0);
|
|
82
|
+
const hashVal = Number(u.quickHash(`${userId}:${experimentName}`)) % totalWeight;
|
|
83
|
+
let cumWeight = 0;
|
|
84
|
+
let chosenVariant = expCfg.variants[0];
|
|
85
|
+
let chosenIdx = 0;
|
|
86
|
+
for (let vi = 0; vi < expCfg.variants.length; vi++) {
|
|
87
|
+
cumWeight += expCfg.variants[vi].weight;
|
|
88
|
+
if (hashVal < cumWeight) { chosenVariant = expCfg.variants[vi]; chosenIdx = vi; break; }
|
|
89
|
+
}
|
|
90
|
+
experimentVariant = chosenVariant.name;
|
|
91
|
+
funnel.conversionRate = Math.min(100, Math.max(1,
|
|
92
|
+
Math.round((funnel.conversionRate || 50) * chosenVariant.conversionMultiplier)));
|
|
93
|
+
funnel.timeToConvert = Math.max(0.1,
|
|
94
|
+
(funnel.timeToConvert || 1) * chosenVariant.ttcMultiplier);
|
|
95
|
+
funnel._experimentName = experimentName;
|
|
96
|
+
funnel._experimentVariant = experimentVariant;
|
|
97
|
+
funnel.sequence = ["$experiment_started", ...funnel.sequence];
|
|
98
|
+
experimentMeta = {
|
|
99
|
+
name: experimentName,
|
|
100
|
+
variantName: experimentVariant,
|
|
101
|
+
variantIndex: chosenIdx,
|
|
102
|
+
conversionMultiplier: chosenVariant.conversionMultiplier,
|
|
103
|
+
ttcMultiplier: chosenVariant.ttcMultiplier,
|
|
104
|
+
startDate: expCfg.startUnix,
|
|
105
|
+
};
|
|
60
106
|
}
|
|
107
|
+
}
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
109
|
+
// Apply persona and world-event modifiers to the funnel BEFORE the hook fires,
|
|
110
|
+
// so funnel-pre sees the effective rate and has final authority.
|
|
111
|
+
if (persona && persona.conversionModifier) {
|
|
112
|
+
funnel.conversionRate = Math.min(100, Math.max(0, Math.round((funnel.conversionRate || 50) * persona.conversionModifier)));
|
|
113
|
+
}
|
|
114
|
+
const resolvedWorldEvents = /** @type {import('../../types').ResolvedWorldEvent[]} */ (config.worldEvents);
|
|
115
|
+
if (resolvedWorldEvents && firstEventTime) {
|
|
116
|
+
for (const we of resolvedWorldEvents) {
|
|
117
|
+
if (firstEventTime >= we.startUnix && firstEventTime < we.endUnix && we.conversionModifier !== 1.0) {
|
|
118
|
+
const seq = funnel.sequence || [];
|
|
119
|
+
const affects = we.affectsEvents;
|
|
120
|
+
if (affects === "*" || (Array.isArray(affects) && seq.some(s => affects.includes(s)))) {
|
|
121
|
+
funnel.conversionRate = Math.min(100, Math.max(0, Math.round((funnel.conversionRate || 50) * we.conversionModifier)));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
68
125
|
}
|
|
69
126
|
|
|
70
|
-
//
|
|
71
|
-
|
|
127
|
+
// funnel-pre hook fires AFTER modifiers — hook has final authority on conversionRate,
|
|
128
|
+
// props, timeToConvert, and sequence.
|
|
129
|
+
await hook(funnel, "funnel-pre", {
|
|
130
|
+
user, profile, scd, funnel, config, firstEventTime,
|
|
131
|
+
datasetStart: context.DATASET_START_SECONDS,
|
|
132
|
+
datasetEnd: context.DATASET_END_SECONDS,
|
|
133
|
+
isFirstFunnel: attemptInfo.isFirstFunnel,
|
|
134
|
+
isBorn: attemptInfo.isBorn,
|
|
135
|
+
attemptsConfig: attemptInfo.attemptsConfig,
|
|
136
|
+
attemptNumber: attemptInfo.attemptNumber,
|
|
137
|
+
totalAttempts: attemptInfo.totalAttempts,
|
|
138
|
+
isFinalAttempt: attemptInfo.isFinalAttempt,
|
|
139
|
+
persona,
|
|
140
|
+
experiment: experimentMeta,
|
|
141
|
+
});
|
|
72
142
|
|
|
73
|
-
// Extract funnel configuration
|
|
143
|
+
// Extract funnel configuration (post-hook — hook's mutations are the final word)
|
|
74
144
|
let {
|
|
75
145
|
sequence,
|
|
76
146
|
conversionRate = 50,
|
|
@@ -83,7 +153,7 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
83
153
|
bindPropsIndex = 0
|
|
84
154
|
} = funnel;
|
|
85
155
|
|
|
86
|
-
const { distinct_id, created, anonymousIds = []
|
|
156
|
+
const { distinct_id, created, anonymousIds = [] } = user;
|
|
87
157
|
const { superProps = {}, groupKeys = [] } = config;
|
|
88
158
|
|
|
89
159
|
// Choose properties for this funnel instance
|
|
@@ -107,57 +177,61 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
107
177
|
chance
|
|
108
178
|
);
|
|
109
179
|
|
|
110
|
-
// Apply persona conversion modifier (before hook, so hook can override via funnel-pre)
|
|
111
|
-
if (persona && persona.conversionModifier) {
|
|
112
|
-
adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * persona.conversionModifier)));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Apply world event conversion modifier if active at firstEventTime
|
|
116
|
-
const resolvedWorldEvents = /** @type {import('../../types').ResolvedWorldEvent[]} */ (config.worldEvents);
|
|
117
|
-
if (resolvedWorldEvents && firstEventTime) {
|
|
118
|
-
for (const we of resolvedWorldEvents) {
|
|
119
|
-
if (firstEventTime >= we.startUnix && firstEventTime < we.endUnix && we.conversionModifier !== 1.0) {
|
|
120
|
-
const affects = we.affectsEvents;
|
|
121
|
-
if (affects === "*" || (Array.isArray(affects) && sequence.some(s => affects.includes(s)))) {
|
|
122
|
-
adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * we.conversionModifier)));
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Apply feature conversion lifts
|
|
129
|
-
const resolvedFeatures = config.features;
|
|
130
|
-
if (resolvedFeatures && firstEventTime) {
|
|
131
|
-
const daysSinceBegin = (firstEventTime - context.FIXED_BEGIN) / 86400;
|
|
132
|
-
for (const feat of resolvedFeatures) {
|
|
133
|
-
if (feat.conversionLift && daysSinceBegin >= feat.launchDay) {
|
|
134
|
-
const daysSinceLaunch = daysSinceBegin - feat.launchDay;
|
|
135
|
-
const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
|
|
136
|
-
const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
|
|
137
|
-
if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
|
|
138
|
-
adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * feat.conversionLift)));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
180
|
// Determine if user converts and how many steps they'll take
|
|
145
181
|
// When experiment mode is active, $experiment_started is prepended to sequence
|
|
146
182
|
// but should not count as a funnel step for conversion purposes
|
|
147
183
|
const conversionStepCount = expName ? sequence.length - 1 : sequence.length;
|
|
148
|
-
|
|
184
|
+
let { doesUserConvert, numStepsUserWillTake } = determineConversion(
|
|
149
185
|
adjustedConversionRate,
|
|
150
186
|
conversionStepCount,
|
|
151
187
|
chance
|
|
152
188
|
);
|
|
153
189
|
|
|
190
|
+
// ── Phase 2 identity helpers ──
|
|
191
|
+
// `firstAuthSeqIdx` is the index in `sequence` (and processedEvents) of the first
|
|
192
|
+
// step whose event config has `isAuthEvent: true`. Used both for truncating failed
|
|
193
|
+
// prior attempts (cap before auth) and for picking per-step identity stamping mode
|
|
194
|
+
// inside isFirstFunnel runs. -1 if no step in this funnel is flagged.
|
|
195
|
+
const eventsByName = (() => {
|
|
196
|
+
const map = new Map();
|
|
197
|
+
for (const e of (config.events || [])) map.set(e.event, e);
|
|
198
|
+
return map;
|
|
199
|
+
})();
|
|
200
|
+
let firstAuthSeqIdx = -1;
|
|
201
|
+
for (let i = 0; i < sequence.length; i++) {
|
|
202
|
+
const ev = eventsByName.get(sequence[i]);
|
|
203
|
+
if (ev && ev.isAuthEvent) { firstAuthSeqIdx = i; break; }
|
|
204
|
+
}
|
|
205
|
+
// When experiment mode prepends $experiment_started, the auth index in
|
|
206
|
+
// `processedEvents` is shifted right by 1.
|
|
207
|
+
const firstAuthProcessedIdx = firstAuthSeqIdx === -1
|
|
208
|
+
? -1
|
|
209
|
+
: (expName ? firstAuthSeqIdx + 1 : firstAuthSeqIdx);
|
|
210
|
+
|
|
211
|
+
// Truncated pre-auth attempt: force the user to drop somewhere strictly before the
|
|
212
|
+
// stitch. If there is no auth step in this funnel, truncation is a no-op (default
|
|
213
|
+
// flow runs). When firstAuthSeqIdx === 0 there's no pre-auth room — force 0 steps
|
|
214
|
+
// (the attempt produces nothing for that user).
|
|
215
|
+
if (attemptInfo.truncateBeforeAuth && firstAuthSeqIdx >= 0 && conversionStepCount > 0) {
|
|
216
|
+
if (firstAuthSeqIdx === 0) {
|
|
217
|
+
numStepsUserWillTake = 0;
|
|
218
|
+
} else {
|
|
219
|
+
numStepsUserWillTake = chance.integer({ min: 1, max: firstAuthSeqIdx });
|
|
220
|
+
}
|
|
221
|
+
doesUserConvert = false;
|
|
222
|
+
}
|
|
223
|
+
|
|
154
224
|
// Get steps user will actually take
|
|
155
225
|
let funnelStepsUserWillTake;
|
|
156
|
-
if (
|
|
157
|
-
//
|
|
158
|
-
funnelStepsUserWillTake = [
|
|
226
|
+
if (attemptInfo.truncateBeforeAuth && firstAuthSeqIdx === 0) {
|
|
227
|
+
// Pre-auth truncation with the stitch at index 0 leaves no room before it → no events.
|
|
228
|
+
funnelStepsUserWillTake = [];
|
|
229
|
+
} else if (expName) {
|
|
230
|
+
// $experiment_started always fires; conversion only applies to actual funnel steps.
|
|
231
|
+
// (Pre-Phase 2 behavior: even when numStepsUserWillTake===0, $experiment_started fires.)
|
|
232
|
+
funnelStepsUserWillTake = [processedEvents[0], ...processedEvents.slice(1, 1 + Math.max(0, numStepsUserWillTake))];
|
|
159
233
|
} else {
|
|
160
|
-
funnelStepsUserWillTake = processedEvents.slice(0, numStepsUserWillTake);
|
|
234
|
+
funnelStepsUserWillTake = processedEvents.slice(0, Math.max(0, numStepsUserWillTake));
|
|
161
235
|
}
|
|
162
236
|
|
|
163
237
|
// Apply ordering strategy
|
|
@@ -187,12 +261,34 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
187
261
|
userCampaign: featureCtx.userCampaign || null,
|
|
188
262
|
userLocation: featureCtx.userLocation || null,
|
|
189
263
|
worldEventsTimeline: featureCtx.worldEventsTimeline || context.config.worldEvents || null,
|
|
190
|
-
resolvedFeatures: featureCtx.resolvedFeatures || context.config.features || null,
|
|
191
|
-
resolvedAnomalies: featureCtx.resolvedAnomalies || context.config.anomalies || null,
|
|
192
264
|
dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
|
|
193
|
-
geo: featureCtx.geo || context.config.geo || null,
|
|
194
265
|
};
|
|
195
266
|
|
|
267
|
+
// Pre-compute per-step stamping modes for execution order. For isFirstFunnel + isBorn
|
|
268
|
+
// runs, the first event in execution order whose config has `isAuthEvent: true` is
|
|
269
|
+
// the stitch event; events before it stamp `device_only`, events after stamp
|
|
270
|
+
// `user_only`. For everything else we stamp `both` (current default identity model).
|
|
271
|
+
const stampingByIndex = new Array(funnelEventsWithTiming.length).fill('both');
|
|
272
|
+
let runAuthExecIdx = -1;
|
|
273
|
+
if (attemptInfo.isFirstFunnel && attemptInfo.isBorn) {
|
|
274
|
+
for (let i = 0; i < funnelEventsWithTiming.length; i++) {
|
|
275
|
+
const evName = funnelEventsWithTiming[i].event;
|
|
276
|
+
const cfg = eventsByName.get(evName);
|
|
277
|
+
if (cfg && cfg.isAuthEvent) { runAuthExecIdx = i; break; }
|
|
278
|
+
}
|
|
279
|
+
for (let i = 0; i < funnelEventsWithTiming.length; i++) {
|
|
280
|
+
if (runAuthExecIdx === -1) {
|
|
281
|
+
stampingByIndex[i] = 'device_only';
|
|
282
|
+
} else if (i < runAuthExecIdx) {
|
|
283
|
+
stampingByIndex[i] = 'device_only';
|
|
284
|
+
} else if (i === runAuthExecIdx) {
|
|
285
|
+
stampingByIndex[i] = 'stitch';
|
|
286
|
+
} else {
|
|
287
|
+
stampingByIndex[i] = 'user_only';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
196
292
|
// Generate actual events with timing
|
|
197
293
|
const finalEvents = await generateFunnelEvents(
|
|
198
294
|
context,
|
|
@@ -200,15 +296,33 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
200
296
|
distinct_id,
|
|
201
297
|
firstEventTime || dayjs(created).unix(),
|
|
202
298
|
anonymousIds,
|
|
203
|
-
sessionIds,
|
|
204
299
|
groupKeys,
|
|
205
|
-
funnelFeatureCtx
|
|
300
|
+
funnelFeatureCtx,
|
|
301
|
+
{ devicePool, stampingByIndex }
|
|
206
302
|
);
|
|
207
303
|
|
|
304
|
+
// Compute the auth-time of the actual stitch event in execution order, if any.
|
|
305
|
+
const authTimeMs = runAuthExecIdx >= 0 && finalEvents[runAuthExecIdx]
|
|
306
|
+
? Date.parse(finalEvents[runAuthExecIdx].time) || null
|
|
307
|
+
: null;
|
|
308
|
+
|
|
208
309
|
// Call post-funnel hook
|
|
209
|
-
await hook(finalEvents, "funnel-post", {
|
|
310
|
+
await hook(finalEvents, "funnel-post", {
|
|
311
|
+
user, profile, scd, funnel, config,
|
|
312
|
+
firstEventTime,
|
|
313
|
+
datasetStart: context.DATASET_START_SECONDS,
|
|
314
|
+
datasetEnd: context.DATASET_END_SECONDS,
|
|
315
|
+
isFirstFunnel: attemptInfo.isFirstFunnel,
|
|
316
|
+
isBorn: attemptInfo.isBorn,
|
|
317
|
+
attemptsConfig: attemptInfo.attemptsConfig,
|
|
318
|
+
attemptNumber: attemptInfo.attemptNumber,
|
|
319
|
+
totalAttempts: attemptInfo.totalAttempts,
|
|
320
|
+
isFinalAttempt: attemptInfo.isFinalAttempt,
|
|
321
|
+
persona,
|
|
322
|
+
experiment: experimentMeta,
|
|
323
|
+
});
|
|
210
324
|
|
|
211
|
-
return [finalEvents, doesUserConvert];
|
|
325
|
+
return [finalEvents, doesUserConvert, authTimeMs];
|
|
212
326
|
}
|
|
213
327
|
|
|
214
328
|
/**
|
|
@@ -394,14 +508,15 @@ function addTimingOffsets(events, timeToConvert, numSteps) {
|
|
|
394
508
|
|
|
395
509
|
/**
|
|
396
510
|
* Generates actual events with proper timing
|
|
397
|
-
* @param {Context} context
|
|
398
|
-
* @param {Array} eventsWithTiming
|
|
399
|
-
* @param {string} distinct_id
|
|
400
|
-
* @param {number} earliestTime
|
|
401
|
-
* @param {Array} anonymousIds
|
|
402
|
-
* @param {Array}
|
|
403
|
-
* @param {
|
|
404
|
-
* @
|
|
511
|
+
* @param {Context} context
|
|
512
|
+
* @param {Array} eventsWithTiming
|
|
513
|
+
* @param {string} distinct_id
|
|
514
|
+
* @param {number} earliestTime
|
|
515
|
+
* @param {Array} anonymousIds
|
|
516
|
+
* @param {Array} groupKeys
|
|
517
|
+
* @param {Object} [featureCtx] - Feature context (persona, worldEvents, dataQuality)
|
|
518
|
+
* @param {Object} [identityArgs] - Phase 2 identity args ({ stampingByIndex, devicePool })
|
|
519
|
+
* @returns {Promise<Array>}
|
|
405
520
|
*/
|
|
406
521
|
async function generateFunnelEvents(
|
|
407
522
|
context,
|
|
@@ -409,25 +524,29 @@ async function generateFunnelEvents(
|
|
|
409
524
|
distinct_id,
|
|
410
525
|
earliestTime,
|
|
411
526
|
anonymousIds,
|
|
412
|
-
sessionIds,
|
|
413
527
|
groupKeys,
|
|
414
|
-
featureCtx = {}
|
|
528
|
+
featureCtx = {},
|
|
529
|
+
identityArgs = null
|
|
415
530
|
) {
|
|
416
531
|
let funnelStartTime;
|
|
532
|
+
const stampingByIndex = (identityArgs && identityArgs.stampingByIndex) || null;
|
|
533
|
+
const devicePool = (identityArgs && identityArgs.devicePool) || null;
|
|
417
534
|
|
|
418
535
|
const finalEvents = await Promise.all(eventsWithTiming.map(async (event, index) => {
|
|
536
|
+
const stamping = stampingByIndex ? stampingByIndex[index] : 'both';
|
|
537
|
+
const identityCtx = (devicePool || stamping !== 'both') ? { stamping, devicePool } : null;
|
|
419
538
|
const newEvent = await makeEvent(
|
|
420
539
|
context,
|
|
421
540
|
distinct_id,
|
|
422
541
|
earliestTime,
|
|
423
542
|
event,
|
|
424
543
|
anonymousIds,
|
|
425
|
-
sessionIds,
|
|
426
544
|
{},
|
|
427
545
|
groupKeys,
|
|
428
|
-
false, // Let all funnel events use TimeSoup for proper time distribution
|
|
429
546
|
false,
|
|
430
|
-
|
|
547
|
+
false,
|
|
548
|
+
featureCtx,
|
|
549
|
+
identityCtx
|
|
431
550
|
);
|
|
432
551
|
|
|
433
552
|
if (index === 0) {
|
|
@@ -446,8 +565,8 @@ async function generateFunnelEvents(
|
|
|
446
565
|
|
|
447
566
|
try {
|
|
448
567
|
let computedTime = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds");
|
|
449
|
-
// Drop events that would land
|
|
450
|
-
if (context.
|
|
568
|
+
// Drop events that would land past the dataset window
|
|
569
|
+
if (context.FIXED_NOW && computedTime.unix() > context.FIXED_NOW) {
|
|
451
570
|
newEvent._drop = true;
|
|
452
571
|
}
|
|
453
572
|
if (computedTime.isValid()) {
|