@ak--47/dungeon-master 1.3.1 → 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/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/lib/core/config-validator.js +136 -157
- 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/user-loop.js +119 -269
- package/lib/utils/utils.js +29 -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 +397 -211
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
|
|
@@ -110,19 +120,32 @@ export async function makeEvent(
|
|
|
110
120
|
eventTemplate.time = dayjs.unix(unixTime).toISOString();
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
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);
|
|
116
140
|
}
|
|
117
|
-
|
|
118
|
-
// Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
|
|
119
|
-
|
|
120
|
-
// Sometimes add user_id (for attribution modeling)
|
|
121
|
-
if (!isFirstEvent && chance.bool({ likelihood: 42 })) {
|
|
141
|
+
if (wantsUser) {
|
|
122
142
|
eventTemplate.user_id = distinct_id;
|
|
123
143
|
}
|
|
144
|
+
// Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
|
|
124
145
|
|
|
125
|
-
//
|
|
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.
|
|
126
149
|
if (!eventTemplate.user_id && !eventTemplate.device_id) {
|
|
127
150
|
eventTemplate.user_id = distinct_id;
|
|
128
151
|
}
|
|
@@ -165,36 +188,13 @@ export async function makeEvent(
|
|
|
165
188
|
addGroupProperties(eventTemplate, groupKeys);
|
|
166
189
|
|
|
167
190
|
// ── Event-level features (applied before hooks, so hooks can override) ──
|
|
168
|
-
const { userLocation, persona, worldEventsTimeline,
|
|
169
|
-
|
|
170
|
-
// Feature 6: Attribution — stamp UTM properties on events as touchpoints
|
|
171
|
-
// Mixpanel attribution analysis needs UTM on EVENTS, not just profiles.
|
|
172
|
-
// Pattern: first events carry acquisition UTM, later events occasionally carry re-engagement UTM.
|
|
173
|
-
if (userCampaign) {
|
|
174
|
-
// ~40% of events carry UTM (simulates page loads, session starts, ad clicks)
|
|
175
|
-
// First events (isFirstEvent) always carry UTM (acquisition touchpoint)
|
|
176
|
-
if (isFirstEvent || chance.bool({ likelihood: 40 })) {
|
|
177
|
-
eventTemplate.utm_source = userCampaign.source;
|
|
178
|
-
eventTemplate.utm_campaign = userCampaign.name;
|
|
179
|
-
if (userCampaign.medium) eventTemplate.utm_medium = userCampaign.medium;
|
|
180
|
-
if (userCampaign.utm_content) eventTemplate.utm_content = userCampaign.utm_content;
|
|
181
|
-
if (userCampaign.utm_term) eventTemplate.utm_term = userCampaign.utm_term;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Feature 7: Sticky geo location
|
|
186
|
-
if (userLocation && geoConfig?.sticky) {
|
|
187
|
-
// Override the random location with user's sticky location
|
|
188
|
-
for (const key in userLocation) {
|
|
189
|
-
eventTemplate[key] = userLocation[key];
|
|
190
|
-
}
|
|
191
|
-
}
|
|
191
|
+
const { userLocation, persona, worldEventsTimeline, dataQuality: dq } = featureCtx;
|
|
192
192
|
|
|
193
|
-
// Perf 1: Compute eventUnix once for all time-based checks. World events
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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.
|
|
196
196
|
let eventUnix = null;
|
|
197
|
-
if (
|
|
197
|
+
if (worldEventsTimeline && eventTemplate.time) {
|
|
198
198
|
eventUnix = dayjs(eventTemplate.time).unix();
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -222,47 +222,6 @@ export async function makeEvent(
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
// Feature 8: Progressive feature adoption
|
|
226
|
-
if (resolvedFeatures && eventUnix !== null) {
|
|
227
|
-
const daysSinceBegin = (eventUnix - context.FIXED_BEGIN) / 86400;
|
|
228
|
-
for (const feat of resolvedFeatures) {
|
|
229
|
-
const affects = feat.affectsEvents;
|
|
230
|
-
if (affects !== "*" && !(Array.isArray(affects) && affects.includes(eventTemplate.event))) continue;
|
|
231
|
-
if (daysSinceBegin < feat.launchDay) {
|
|
232
|
-
if (feat.defaultBefore !== undefined) {
|
|
233
|
-
eventTemplate[feat.property] = feat.defaultBefore;
|
|
234
|
-
}
|
|
235
|
-
} else {
|
|
236
|
-
const daysSinceLaunch = daysSinceBegin - feat.launchDay;
|
|
237
|
-
const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
|
|
238
|
-
const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
|
|
239
|
-
if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
|
|
240
|
-
eventTemplate[feat.property] = u.pickRandom(feat._adoptedValues);
|
|
241
|
-
} else if (feat.defaultBefore !== undefined) {
|
|
242
|
-
eventTemplate[feat.property] = feat.defaultBefore;
|
|
243
|
-
} else if (feat.values.length > 0) {
|
|
244
|
-
eventTemplate[feat.property] = feat.values[0];
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Feature 9: Anomaly extreme values
|
|
251
|
-
if (resolvedAnomalies && eventTemplate.event) {
|
|
252
|
-
for (const a of resolvedAnomalies) {
|
|
253
|
-
if (a.type === 'extreme_value' && a.event === eventTemplate.event && a.property) {
|
|
254
|
-
if (chance.bool({ likelihood: (a.frequency ?? 0.001) * 100 })) {
|
|
255
|
-
const currentVal = eventTemplate[a.property];
|
|
256
|
-
if (typeof currentVal === 'number') {
|
|
257
|
-
eventTemplate[a.property] = currentVal * (a.multiplier || 10);
|
|
258
|
-
}
|
|
259
|
-
if (a.tag) eventTemplate._anomaly = a.tag;
|
|
260
|
-
if (a.properties) Object.assign(eventTemplate, a.properties);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
225
|
// Feature 4: Data quality — null injection and timezone confusion
|
|
267
226
|
if (dq) {
|
|
268
227
|
// Null injection
|
|
@@ -287,10 +246,7 @@ export async function makeEvent(
|
|
|
287
246
|
}
|
|
288
247
|
}
|
|
289
248
|
|
|
290
|
-
|
|
291
|
-
const distinctId = eventTemplate.user_id || eventTemplate.device_id || eventTemplate.distinct_id || distinct_id;
|
|
292
|
-
const tuple = `${eventTemplate.event}-${eventTemplate.time}-${distinctId}`;
|
|
293
|
-
eventTemplate.insert_id = u.quickHash(tuple);
|
|
249
|
+
eventTemplate.insert_id = randomUUID();
|
|
294
250
|
|
|
295
251
|
// Call hook if configured (hooks override everything — they are the final authority)
|
|
296
252
|
const { hook } = config;
|
|
@@ -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,52 +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
|
-
//
|
|
127
|
+
// funnel-pre hook fires AFTER modifiers — hook has final authority on conversionRate,
|
|
128
|
+
// props, timeToConvert, and sequence.
|
|
71
129
|
await hook(funnel, "funnel-pre", {
|
|
72
130
|
user, profile, scd, funnel, config, firstEventTime,
|
|
73
131
|
datasetStart: context.DATASET_START_SECONDS,
|
|
74
|
-
datasetEnd: context.DATASET_END_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,
|
|
75
141
|
});
|
|
76
142
|
|
|
77
|
-
// Extract funnel configuration
|
|
143
|
+
// Extract funnel configuration (post-hook — hook's mutations are the final word)
|
|
78
144
|
let {
|
|
79
145
|
sequence,
|
|
80
146
|
conversionRate = 50,
|
|
@@ -87,7 +153,7 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
87
153
|
bindPropsIndex = 0
|
|
88
154
|
} = funnel;
|
|
89
155
|
|
|
90
|
-
const { distinct_id, created, anonymousIds = []
|
|
156
|
+
const { distinct_id, created, anonymousIds = [] } = user;
|
|
91
157
|
const { superProps = {}, groupKeys = [] } = config;
|
|
92
158
|
|
|
93
159
|
// Choose properties for this funnel instance
|
|
@@ -111,57 +177,61 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
111
177
|
chance
|
|
112
178
|
);
|
|
113
179
|
|
|
114
|
-
// Apply persona conversion modifier (before hook, so hook can override via funnel-pre)
|
|
115
|
-
if (persona && persona.conversionModifier) {
|
|
116
|
-
adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * persona.conversionModifier)));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Apply world event conversion modifier if active at firstEventTime
|
|
120
|
-
const resolvedWorldEvents = /** @type {import('../../types').ResolvedWorldEvent[]} */ (config.worldEvents);
|
|
121
|
-
if (resolvedWorldEvents && firstEventTime) {
|
|
122
|
-
for (const we of resolvedWorldEvents) {
|
|
123
|
-
if (firstEventTime >= we.startUnix && firstEventTime < we.endUnix && we.conversionModifier !== 1.0) {
|
|
124
|
-
const affects = we.affectsEvents;
|
|
125
|
-
if (affects === "*" || (Array.isArray(affects) && sequence.some(s => affects.includes(s)))) {
|
|
126
|
-
adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * we.conversionModifier)));
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Apply feature conversion lifts
|
|
133
|
-
const resolvedFeatures = config.features;
|
|
134
|
-
if (resolvedFeatures && firstEventTime) {
|
|
135
|
-
const daysSinceBegin = (firstEventTime - context.FIXED_BEGIN) / 86400;
|
|
136
|
-
for (const feat of resolvedFeatures) {
|
|
137
|
-
if (feat.conversionLift && daysSinceBegin >= feat.launchDay) {
|
|
138
|
-
const daysSinceLaunch = daysSinceBegin - feat.launchDay;
|
|
139
|
-
const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
|
|
140
|
-
const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
|
|
141
|
-
if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
|
|
142
|
-
adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * feat.conversionLift)));
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
180
|
// Determine if user converts and how many steps they'll take
|
|
149
181
|
// When experiment mode is active, $experiment_started is prepended to sequence
|
|
150
182
|
// but should not count as a funnel step for conversion purposes
|
|
151
183
|
const conversionStepCount = expName ? sequence.length - 1 : sequence.length;
|
|
152
|
-
|
|
184
|
+
let { doesUserConvert, numStepsUserWillTake } = determineConversion(
|
|
153
185
|
adjustedConversionRate,
|
|
154
186
|
conversionStepCount,
|
|
155
187
|
chance
|
|
156
188
|
);
|
|
157
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
|
+
|
|
158
224
|
// Get steps user will actually take
|
|
159
225
|
let funnelStepsUserWillTake;
|
|
160
|
-
if (
|
|
161
|
-
//
|
|
162
|
-
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))];
|
|
163
233
|
} else {
|
|
164
|
-
funnelStepsUserWillTake = processedEvents.slice(0, numStepsUserWillTake);
|
|
234
|
+
funnelStepsUserWillTake = processedEvents.slice(0, Math.max(0, numStepsUserWillTake));
|
|
165
235
|
}
|
|
166
236
|
|
|
167
237
|
// Apply ordering strategy
|
|
@@ -191,12 +261,34 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
191
261
|
userCampaign: featureCtx.userCampaign || null,
|
|
192
262
|
userLocation: featureCtx.userLocation || null,
|
|
193
263
|
worldEventsTimeline: featureCtx.worldEventsTimeline || context.config.worldEvents || null,
|
|
194
|
-
resolvedFeatures: featureCtx.resolvedFeatures || context.config.features || null,
|
|
195
|
-
resolvedAnomalies: featureCtx.resolvedAnomalies || context.config.anomalies || null,
|
|
196
264
|
dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
|
|
197
|
-
geo: featureCtx.geo || context.config.geo || null,
|
|
198
265
|
};
|
|
199
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
|
+
|
|
200
292
|
// Generate actual events with timing
|
|
201
293
|
const finalEvents = await generateFunnelEvents(
|
|
202
294
|
context,
|
|
@@ -204,19 +296,33 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
204
296
|
distinct_id,
|
|
205
297
|
firstEventTime || dayjs(created).unix(),
|
|
206
298
|
anonymousIds,
|
|
207
|
-
sessionIds,
|
|
208
299
|
groupKeys,
|
|
209
|
-
funnelFeatureCtx
|
|
300
|
+
funnelFeatureCtx,
|
|
301
|
+
{ devicePool, stampingByIndex }
|
|
210
302
|
);
|
|
211
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
|
+
|
|
212
309
|
// Call post-funnel hook
|
|
213
310
|
await hook(finalEvents, "funnel-post", {
|
|
214
311
|
user, profile, scd, funnel, config,
|
|
312
|
+
firstEventTime,
|
|
215
313
|
datasetStart: context.DATASET_START_SECONDS,
|
|
216
|
-
datasetEnd: context.DATASET_END_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,
|
|
217
323
|
});
|
|
218
324
|
|
|
219
|
-
return [finalEvents, doesUserConvert];
|
|
325
|
+
return [finalEvents, doesUserConvert, authTimeMs];
|
|
220
326
|
}
|
|
221
327
|
|
|
222
328
|
/**
|
|
@@ -402,14 +508,15 @@ function addTimingOffsets(events, timeToConvert, numSteps) {
|
|
|
402
508
|
|
|
403
509
|
/**
|
|
404
510
|
* Generates actual events with proper timing
|
|
405
|
-
* @param {Context} context
|
|
406
|
-
* @param {Array} eventsWithTiming
|
|
407
|
-
* @param {string} distinct_id
|
|
408
|
-
* @param {number} earliestTime
|
|
409
|
-
* @param {Array} anonymousIds
|
|
410
|
-
* @param {Array}
|
|
411
|
-
* @param {
|
|
412
|
-
* @
|
|
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>}
|
|
413
520
|
*/
|
|
414
521
|
async function generateFunnelEvents(
|
|
415
522
|
context,
|
|
@@ -417,25 +524,29 @@ async function generateFunnelEvents(
|
|
|
417
524
|
distinct_id,
|
|
418
525
|
earliestTime,
|
|
419
526
|
anonymousIds,
|
|
420
|
-
sessionIds,
|
|
421
527
|
groupKeys,
|
|
422
|
-
featureCtx = {}
|
|
528
|
+
featureCtx = {},
|
|
529
|
+
identityArgs = null
|
|
423
530
|
) {
|
|
424
531
|
let funnelStartTime;
|
|
532
|
+
const stampingByIndex = (identityArgs && identityArgs.stampingByIndex) || null;
|
|
533
|
+
const devicePool = (identityArgs && identityArgs.devicePool) || null;
|
|
425
534
|
|
|
426
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;
|
|
427
538
|
const newEvent = await makeEvent(
|
|
428
539
|
context,
|
|
429
540
|
distinct_id,
|
|
430
541
|
earliestTime,
|
|
431
542
|
event,
|
|
432
543
|
anonymousIds,
|
|
433
|
-
sessionIds,
|
|
434
544
|
{},
|
|
435
545
|
groupKeys,
|
|
436
|
-
false, // Let all funnel events use TimeSoup for proper time distribution
|
|
437
546
|
false,
|
|
438
|
-
|
|
547
|
+
false,
|
|
548
|
+
featureCtx,
|
|
549
|
+
identityCtx
|
|
439
550
|
);
|
|
440
551
|
|
|
441
552
|
if (index === 0) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function toMs(t) {
|
|
2
|
+
if (typeof t === 'number') return t > 1e12 ? t : t > 1e9 ? t * 1000 : t;
|
|
3
|
+
return Date.parse(t);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function writeTime(event, ms) {
|
|
7
|
+
if (typeof event.time === 'string' || event.time === undefined) {
|
|
8
|
+
event.time = new Date(ms).toISOString();
|
|
9
|
+
} else if (event.time > 1e12) {
|
|
10
|
+
event.time = ms;
|
|
11
|
+
} else {
|
|
12
|
+
event.time = ms / 1000;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function simpleHashFloat(s) {
|
|
17
|
+
let h = 0x811c9dc5;
|
|
18
|
+
for (let i = 0; i < s.length; i++) {
|
|
19
|
+
h ^= s.charCodeAt(i);
|
|
20
|
+
h = Math.imul(h, 0x01000193);
|
|
21
|
+
}
|
|
22
|
+
return ((h >>> 0) % 1000) / 1000;
|
|
23
|
+
}
|