make-mp-data 1.5.56 → 2.0.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/.claude/settings.local.json +20 -0
- package/.gcloudignore +2 -1
- package/.vscode/launch.json +6 -17
- package/.vscode/settings.json +31 -2
- package/dungeons/media.js +371 -0
- package/index.js +353 -1766
- package/{components → lib/cli}/cli.js +21 -6
- package/lib/cloud-function.js +20 -0
- package/lib/core/config-validator.js +248 -0
- package/lib/core/context.js +180 -0
- package/lib/core/storage.js +268 -0
- package/{components → lib/data}/defaults.js +17 -14
- package/lib/generators/adspend.js +133 -0
- package/lib/generators/events.js +242 -0
- package/lib/generators/funnels.js +330 -0
- package/lib/generators/mirror.js +168 -0
- package/lib/generators/profiles.js +93 -0
- package/lib/generators/scd.js +102 -0
- package/lib/orchestrators/mixpanel-sender.js +222 -0
- package/lib/orchestrators/user-loop.js +194 -0
- package/lib/orchestrators/worker-manager.js +200 -0
- package/{components → lib/utils}/ai.js +8 -36
- package/{components → lib/utils}/chart.js +9 -9
- package/{components → lib/utils}/project.js +4 -4
- package/{components → lib/utils}/utils.js +35 -23
- package/package.json +15 -15
- package/scripts/dana.mjs +137 -0
- package/scripts/new-dungeon.sh +7 -6
- package/scripts/update-deps.sh +2 -1
- package/tests/cli.test.js +28 -25
- package/tests/e2e.test.js +38 -36
- package/tests/int.test.js +151 -56
- package/tests/testSoup.mjs +1 -1
- package/tests/unit.test.js +15 -14
- package/tsconfig.json +1 -1
- package/types.d.ts +68 -11
- package/vitest.config.js +47 -0
- package/log.json +0 -1678
- package/tests/jest.config.js +0 -47
- /package/{components → lib/utils}/prompt.txt +0 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event generator module
|
|
3
|
+
* Creates individual Mixpanel events with realistic properties and timing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types').Dungeon} Config */
|
|
7
|
+
/** @typedef {import('../../types').EventConfig} EventConfig */
|
|
8
|
+
/** @typedef {import('../../types').ValueValid} ValueValid */
|
|
9
|
+
/** @typedef {import('../../types').EventSchema} EventSchema */
|
|
10
|
+
/** @typedef {import('../../types').Context} Context */
|
|
11
|
+
|
|
12
|
+
import dayjs from "dayjs";
|
|
13
|
+
import { md5 } from "ak-tools";
|
|
14
|
+
import * as u from "../utils/utils.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a Mixpanel event with a flat shape
|
|
18
|
+
* @param {Context} context - Context object containing config, defaults, etc.
|
|
19
|
+
* @param {string} distinct_id - User identifier
|
|
20
|
+
* @param {number} earliestTime - Unix timestamp for earliest possible event time
|
|
21
|
+
* @param {Object} chosenEvent - Event configuration object
|
|
22
|
+
* @param {string[]} [anonymousIds] - Array of anonymous/device IDs
|
|
23
|
+
* @param {string[]} [sessionIds] - Array of session IDs
|
|
24
|
+
* @param {Object} [superProps] - Super properties to add to event
|
|
25
|
+
* @param {Array} [groupKeys] - Group key configurations
|
|
26
|
+
* @param {boolean} [isFirstEvent] - Whether this is the user's first event
|
|
27
|
+
* @param {boolean} [skipDefaults] - Whether to skip adding default properties
|
|
28
|
+
* @returns {Promise<Object>} Generated event object
|
|
29
|
+
*/
|
|
30
|
+
export async function makeEvent(
|
|
31
|
+
context,
|
|
32
|
+
distinct_id,
|
|
33
|
+
earliestTime,
|
|
34
|
+
chosenEvent,
|
|
35
|
+
anonymousIds = [],
|
|
36
|
+
sessionIds = [],
|
|
37
|
+
superProps = {},
|
|
38
|
+
groupKeys = [],
|
|
39
|
+
isFirstEvent = false,
|
|
40
|
+
skipDefaults = false
|
|
41
|
+
) {
|
|
42
|
+
// Validate required parameters
|
|
43
|
+
if (!distinct_id) throw new Error("no distinct_id");
|
|
44
|
+
if (!earliestTime) throw new Error("no earliestTime");
|
|
45
|
+
if (!chosenEvent) throw new Error("no chosenEvent");
|
|
46
|
+
|
|
47
|
+
// Update context metrics
|
|
48
|
+
context.incrementOperations();
|
|
49
|
+
context.incrementEvents();
|
|
50
|
+
|
|
51
|
+
const { config, defaults } = context;
|
|
52
|
+
const chance = u.getChance();
|
|
53
|
+
|
|
54
|
+
// Extract soup configuration for time distribution
|
|
55
|
+
const { mean = 0, deviation = 2, peaks = 5 } = config.soup || {};
|
|
56
|
+
|
|
57
|
+
// Extract feature flags from config
|
|
58
|
+
const {
|
|
59
|
+
hasAndroidDevices = false,
|
|
60
|
+
hasBrowser = false,
|
|
61
|
+
hasCampaigns = false,
|
|
62
|
+
hasDesktopDevices = false,
|
|
63
|
+
hasIOSDevices = false,
|
|
64
|
+
hasLocation = false
|
|
65
|
+
} = config;
|
|
66
|
+
|
|
67
|
+
// Create base event template
|
|
68
|
+
const eventTemplate = {
|
|
69
|
+
event: chosenEvent.event,
|
|
70
|
+
source: "dm4",
|
|
71
|
+
time: "",
|
|
72
|
+
insert_id: "",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let defaultProps = {};
|
|
76
|
+
let devicePool = [];
|
|
77
|
+
|
|
78
|
+
// Add default properties based on configuration
|
|
79
|
+
if (hasLocation) {
|
|
80
|
+
defaultProps.location = u.shuffleArray(defaults.locationsEvents()).pop();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (hasBrowser) {
|
|
84
|
+
defaultProps.browser = u.choose(defaults.browsers());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Build device pool based on enabled device types
|
|
88
|
+
if (hasAndroidDevices) devicePool.push(defaults.androidDevices());
|
|
89
|
+
if (hasIOSDevices) devicePool.push(defaults.iOSDevices());
|
|
90
|
+
if (hasDesktopDevices) devicePool.push(defaults.desktopDevices());
|
|
91
|
+
|
|
92
|
+
// Add campaigns with attribution likelihood
|
|
93
|
+
if (hasCampaigns && chance.bool({ likelihood: 25 })) {
|
|
94
|
+
defaultProps.campaigns = u.shuffleArray(defaults.campaigns()).pop();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Select device from pool
|
|
98
|
+
const devices = devicePool.flat();
|
|
99
|
+
if (devices.length) {
|
|
100
|
+
defaultProps.device = u.shuffleArray(devices).pop();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Set event time using TimeSoup for realistic distribution
|
|
104
|
+
if (earliestTime) {
|
|
105
|
+
if (isFirstEvent) {
|
|
106
|
+
eventTemplate.time = dayjs.unix(earliestTime).toISOString();
|
|
107
|
+
} else {
|
|
108
|
+
eventTemplate.time = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Add anonymous and session identifiers
|
|
113
|
+
if (anonymousIds.length) {
|
|
114
|
+
eventTemplate.device_id = chance.pickone(anonymousIds);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (sessionIds.length) {
|
|
118
|
+
eventTemplate.session_id = chance.pickone(sessionIds);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Sometimes add user_id (for attribution modeling)
|
|
122
|
+
if (!isFirstEvent && chance.bool({ likelihood: 42 })) {
|
|
123
|
+
eventTemplate.user_id = distinct_id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ensure we have either user_id or device_id
|
|
127
|
+
if (!eventTemplate.user_id && !eventTemplate.device_id) {
|
|
128
|
+
eventTemplate.user_id = distinct_id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Merge custom properties with super properties
|
|
132
|
+
const props = { ...chosenEvent.properties, ...superProps };
|
|
133
|
+
|
|
134
|
+
// Add custom properties from event configuration
|
|
135
|
+
for (const key in props) {
|
|
136
|
+
try {
|
|
137
|
+
eventTemplate[key] = u.choose(props[key]);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`error with ${key} in ${chosenEvent.event} event`, e);
|
|
140
|
+
// Continue processing other properties
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Add default properties if not skipped
|
|
145
|
+
if (!skipDefaults) {
|
|
146
|
+
addDefaultProperties(eventTemplate, defaultProps);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Add group properties
|
|
150
|
+
addGroupProperties(eventTemplate, groupKeys);
|
|
151
|
+
|
|
152
|
+
// Generate unique insert_id
|
|
153
|
+
eventTemplate.insert_id = md5(JSON.stringify(eventTemplate));
|
|
154
|
+
|
|
155
|
+
// Apply time shift to move events to current timeline
|
|
156
|
+
if (earliestTime) {
|
|
157
|
+
const timeShift = dayjs().add(2, "day").diff(dayjs.unix(context.FIXED_NOW), "seconds");
|
|
158
|
+
const timeShifted = dayjs(eventTemplate.time).add(timeShift, "seconds").toISOString();
|
|
159
|
+
eventTemplate.time = timeShifted;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return eventTemplate;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Adds default properties to an event template
|
|
167
|
+
* Handles complex nested property structures
|
|
168
|
+
* @param {Object} eventTemplate - Event object to modify
|
|
169
|
+
* @param {Object} defaultProps - Default properties to add
|
|
170
|
+
*/
|
|
171
|
+
function addDefaultProperties(eventTemplate, defaultProps) {
|
|
172
|
+
for (const key in defaultProps) {
|
|
173
|
+
if (Array.isArray(defaultProps[key])) {
|
|
174
|
+
const choice = u.choose(defaultProps[key]);
|
|
175
|
+
|
|
176
|
+
if (typeof choice === "string") {
|
|
177
|
+
if (!eventTemplate[key]) eventTemplate[key] = choice;
|
|
178
|
+
}
|
|
179
|
+
else if (Array.isArray(choice)) {
|
|
180
|
+
for (const subChoice of choice) {
|
|
181
|
+
if (!eventTemplate[key]) eventTemplate[key] = subChoice;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (typeof choice === "object") {
|
|
185
|
+
addNestedObjectProperties(eventTemplate, choice);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (typeof defaultProps[key] === "object") {
|
|
189
|
+
addNestedObjectProperties(eventTemplate, defaultProps[key]);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
if (!eventTemplate[key]) eventTemplate[key] = defaultProps[key];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Adds nested object properties to event template
|
|
199
|
+
* @param {Object} eventTemplate - Event object to modify
|
|
200
|
+
* @param {Object} obj - Object with properties to add
|
|
201
|
+
*/
|
|
202
|
+
function addNestedObjectProperties(eventTemplate, obj) {
|
|
203
|
+
for (const subKey in obj) {
|
|
204
|
+
if (typeof obj[subKey] === "string") {
|
|
205
|
+
if (!eventTemplate[subKey]) eventTemplate[subKey] = obj[subKey];
|
|
206
|
+
}
|
|
207
|
+
else if (Array.isArray(obj[subKey])) {
|
|
208
|
+
const subChoice = u.choose(obj[subKey]);
|
|
209
|
+
if (!eventTemplate[subKey]) eventTemplate[subKey] = subChoice;
|
|
210
|
+
}
|
|
211
|
+
else if (typeof obj[subKey] === "object") {
|
|
212
|
+
for (const subSubKey in obj[subKey]) {
|
|
213
|
+
if (!eventTemplate[subSubKey]) {
|
|
214
|
+
eventTemplate[subSubKey] = obj[subKey][subSubKey];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Adds group properties to event based on group key configuration
|
|
223
|
+
* @param {Object} eventTemplate - Event object to modify
|
|
224
|
+
* @param {Array} groupKeys - Array of group key configurations
|
|
225
|
+
*/
|
|
226
|
+
function addGroupProperties(eventTemplate, groupKeys) {
|
|
227
|
+
for (const groupPair of groupKeys) {
|
|
228
|
+
const groupKey = groupPair[0];
|
|
229
|
+
const groupCardinality = groupPair[1];
|
|
230
|
+
const groupEvents = groupPair[2] || [];
|
|
231
|
+
|
|
232
|
+
// Empty array for group events means all events get the group property
|
|
233
|
+
if (!groupEvents.length) {
|
|
234
|
+
eventTemplate[groupKey] = u.pick(u.weighNumRange(1, groupCardinality));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Only add group property if event is in the specified group events
|
|
238
|
+
if (groupEvents.includes(eventTemplate.event)) {
|
|
239
|
+
eventTemplate[groupKey] = u.pick(u.weighNumRange(1, groupCardinality));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funnel generator module
|
|
3
|
+
* Creates conversion sequences with realistic timing and ordering
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types').Context} Context */
|
|
7
|
+
|
|
8
|
+
import dayjs from "dayjs";
|
|
9
|
+
import { clone } from "ak-tools";
|
|
10
|
+
import * as u from "../utils/utils.js";
|
|
11
|
+
import { makeEvent } from "./events.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a funnel (sequence of events) for a user with conversion logic
|
|
15
|
+
* @param {Context} context - Context object containing config, defaults, etc.
|
|
16
|
+
* @param {Object} funnel - Funnel configuration
|
|
17
|
+
* @param {Object} user - User object with distinct_id, created, etc.
|
|
18
|
+
* @param {number} firstEventTime - Unix timestamp for first event
|
|
19
|
+
* @param {Object} profile - User profile object
|
|
20
|
+
* @param {Object} scd - Slowly changing dimensions object
|
|
21
|
+
* @returns {Promise<[Array, boolean]>} Tuple of [events, didConvert]
|
|
22
|
+
*/
|
|
23
|
+
export async function makeFunnel(context, funnel, user, firstEventTime, profile = {}, scd = {}) {
|
|
24
|
+
if (!funnel) throw new Error("no funnel");
|
|
25
|
+
if (!user) throw new Error("no user");
|
|
26
|
+
|
|
27
|
+
const { config } = context;
|
|
28
|
+
const chance = u.getChance();
|
|
29
|
+
const { hook = async (a) => a } = config;
|
|
30
|
+
|
|
31
|
+
// Get session start events if configured
|
|
32
|
+
const sessionStartEvents = config.events?.filter(a => a.isSessionStartEvent) || [];
|
|
33
|
+
|
|
34
|
+
// Call pre-funnel hook
|
|
35
|
+
await hook(funnel, "funnel-pre", { user, profile, scd, funnel, config });
|
|
36
|
+
|
|
37
|
+
// Extract funnel configuration
|
|
38
|
+
let {
|
|
39
|
+
sequence,
|
|
40
|
+
conversionRate = 50,
|
|
41
|
+
order = 'sequential',
|
|
42
|
+
timeToConvert = 1,
|
|
43
|
+
props = {},
|
|
44
|
+
requireRepeats = false,
|
|
45
|
+
} = funnel;
|
|
46
|
+
|
|
47
|
+
const { distinct_id, created, anonymousIds = [], sessionIds = [] } = user;
|
|
48
|
+
const { superProps = {}, groupKeys = [] } = config;
|
|
49
|
+
|
|
50
|
+
// Choose properties for this funnel instance
|
|
51
|
+
const chosenFunnelProps = { ...props, ...superProps };
|
|
52
|
+
for (const key in props) {
|
|
53
|
+
try {
|
|
54
|
+
chosenFunnelProps[key] = u.choose(chosenFunnelProps[key]);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error(`error with ${key} in ${funnel.sequence.join(" > ")} funnel`, e);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build event specifications for funnel steps
|
|
61
|
+
const funnelPossibleEvents = buildFunnelEvents(context, sequence, chosenFunnelProps);
|
|
62
|
+
|
|
63
|
+
// Handle repeat logic and conversion rate adjustment
|
|
64
|
+
const { processedEvents, adjustedConversionRate } = processEventRepeats(
|
|
65
|
+
funnelPossibleEvents,
|
|
66
|
+
requireRepeats,
|
|
67
|
+
conversionRate,
|
|
68
|
+
chance
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Determine if user converts and how many steps they'll take
|
|
72
|
+
const { doesUserConvert, numStepsUserWillTake } = determineConversion(
|
|
73
|
+
adjustedConversionRate,
|
|
74
|
+
sequence.length,
|
|
75
|
+
chance
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Get steps user will actually take
|
|
79
|
+
const funnelStepsUserWillTake = processedEvents.slice(0, numStepsUserWillTake);
|
|
80
|
+
|
|
81
|
+
// Apply ordering strategy
|
|
82
|
+
const funnelActualOrder = applyOrderingStrategy(
|
|
83
|
+
funnelStepsUserWillTake,
|
|
84
|
+
order,
|
|
85
|
+
config,
|
|
86
|
+
sequence
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Add timing offsets to events
|
|
90
|
+
const funnelEventsWithTiming = addTimingOffsets(
|
|
91
|
+
funnelActualOrder,
|
|
92
|
+
timeToConvert,
|
|
93
|
+
numStepsUserWillTake
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Add session start event if configured
|
|
97
|
+
if (sessionStartEvents.length) {
|
|
98
|
+
const sessionStartEvent = chance.pickone(sessionStartEvents);
|
|
99
|
+
sessionStartEvent.relativeTimeMs = -15000; // 15 seconds before funnel
|
|
100
|
+
funnelEventsWithTiming.push(sessionStartEvent);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Generate actual events with timing
|
|
104
|
+
const finalEvents = await generateFunnelEvents(
|
|
105
|
+
context,
|
|
106
|
+
funnelEventsWithTiming,
|
|
107
|
+
distinct_id,
|
|
108
|
+
firstEventTime || dayjs(created).unix(),
|
|
109
|
+
anonymousIds,
|
|
110
|
+
sessionIds,
|
|
111
|
+
groupKeys
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Call post-funnel hook
|
|
115
|
+
await hook(finalEvents, "funnel-post", { user, profile, scd, funnel, config });
|
|
116
|
+
|
|
117
|
+
return [finalEvents, doesUserConvert];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Builds event specifications for funnel steps
|
|
122
|
+
* @param {Context} context - Context object
|
|
123
|
+
* @param {Array} sequence - Array of event names
|
|
124
|
+
* @param {Object} chosenFunnelProps - Properties to apply to all events
|
|
125
|
+
* @returns {Array} Array of event specifications
|
|
126
|
+
*/
|
|
127
|
+
function buildFunnelEvents(context, sequence, chosenFunnelProps) {
|
|
128
|
+
const { config } = context;
|
|
129
|
+
|
|
130
|
+
return sequence.map((eventName) => {
|
|
131
|
+
const foundEvent = config.events?.find((e) => e.event === eventName);
|
|
132
|
+
const eventSpec = clone(foundEvent) || { event: eventName, properties: {} };
|
|
133
|
+
|
|
134
|
+
// Process event properties
|
|
135
|
+
for (const key in eventSpec.properties) {
|
|
136
|
+
try {
|
|
137
|
+
eventSpec.properties[key] = u.choose(eventSpec.properties[key]);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`error with ${key} in ${eventSpec.event} event`, e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Clean up funnel-specific properties
|
|
144
|
+
delete eventSpec.isFirstEvent;
|
|
145
|
+
delete eventSpec.weight;
|
|
146
|
+
|
|
147
|
+
// Merge funnel properties
|
|
148
|
+
eventSpec.properties = { ...eventSpec.properties, ...chosenFunnelProps };
|
|
149
|
+
|
|
150
|
+
return eventSpec;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Processes event repeats and adjusts conversion rate
|
|
156
|
+
* @param {Array} events - Array of event specifications
|
|
157
|
+
* @param {boolean} requireRepeats - Whether repeats are required
|
|
158
|
+
* @param {number} conversionRate - Base conversion rate
|
|
159
|
+
* @param {Object} chance - Chance.js instance
|
|
160
|
+
* @returns {Object} Object with processedEvents and adjustedConversionRate
|
|
161
|
+
*/
|
|
162
|
+
function processEventRepeats(events, requireRepeats, conversionRate, chance) {
|
|
163
|
+
let adjustedConversionRate = conversionRate;
|
|
164
|
+
|
|
165
|
+
const processedEvents = events.reduce((acc, step) => {
|
|
166
|
+
if (!requireRepeats) {
|
|
167
|
+
if (acc.find(e => e.event === step.event)) {
|
|
168
|
+
if (chance.bool({ likelihood: 50 })) {
|
|
169
|
+
adjustedConversionRate = Math.floor(adjustedConversionRate * 1.35); // Increase conversion rate
|
|
170
|
+
acc.push(step);
|
|
171
|
+
} else {
|
|
172
|
+
adjustedConversionRate = Math.floor(adjustedConversionRate * 0.70); // Reduce conversion rate
|
|
173
|
+
return acc; // Skip the step
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
acc.push(step);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
acc.push(step);
|
|
180
|
+
}
|
|
181
|
+
return acc;
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
// Clamp conversion rate
|
|
185
|
+
if (adjustedConversionRate > 100) adjustedConversionRate = 100;
|
|
186
|
+
if (adjustedConversionRate < 0) adjustedConversionRate = 0;
|
|
187
|
+
|
|
188
|
+
return { processedEvents, adjustedConversionRate };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Determines if user converts and how many steps they'll take
|
|
193
|
+
* @param {number} conversionRate - Adjusted conversion rate
|
|
194
|
+
* @param {number} totalSteps - Total number of steps in funnel
|
|
195
|
+
* @param {Object} chance - Chance.js instance
|
|
196
|
+
* @returns {Object} Object with doesUserConvert and numStepsUserWillTake
|
|
197
|
+
*/
|
|
198
|
+
function determineConversion(conversionRate, totalSteps, chance) {
|
|
199
|
+
const doesUserConvert = chance.bool({ likelihood: conversionRate });
|
|
200
|
+
const numStepsUserWillTake = doesUserConvert ?
|
|
201
|
+
totalSteps :
|
|
202
|
+
u.integer(1, totalSteps - 1);
|
|
203
|
+
|
|
204
|
+
return { doesUserConvert, numStepsUserWillTake };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Applies ordering strategy to funnel steps
|
|
209
|
+
* @param {Array} steps - Funnel steps to order
|
|
210
|
+
* @param {string} order - Ordering strategy
|
|
211
|
+
* @param {Object} config - Configuration object
|
|
212
|
+
* @param {Array} sequence - Original sequence for interrupted mode
|
|
213
|
+
* @returns {Array} Ordered funnel steps
|
|
214
|
+
*/
|
|
215
|
+
function applyOrderingStrategy(steps, order, config, sequence) {
|
|
216
|
+
switch (order) {
|
|
217
|
+
case "sequential":
|
|
218
|
+
return steps;
|
|
219
|
+
case "random":
|
|
220
|
+
return u.shuffleArray(steps);
|
|
221
|
+
case "first-fixed":
|
|
222
|
+
return u.shuffleExceptFirst(steps);
|
|
223
|
+
case "last-fixed":
|
|
224
|
+
return u.shuffleExceptLast(steps);
|
|
225
|
+
case "first-and-last-fixed":
|
|
226
|
+
return u.fixFirstAndLast(steps);
|
|
227
|
+
case "middle-fixed":
|
|
228
|
+
return u.shuffleOutside(steps);
|
|
229
|
+
case "interrupted":
|
|
230
|
+
const potentialSubstitutes = config.events
|
|
231
|
+
?.filter(e => !e.isFirstEvent)
|
|
232
|
+
?.filter(e => !sequence.includes(e.event)) || [];
|
|
233
|
+
return u.interruptArray(steps, potentialSubstitutes);
|
|
234
|
+
default:
|
|
235
|
+
return steps;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Adds timing offsets to funnel events
|
|
241
|
+
* @param {Array} events - Events to add timing to
|
|
242
|
+
* @param {number} timeToConvert - Total time to convert (in hours)
|
|
243
|
+
* @param {number} numSteps - Number of steps in funnel
|
|
244
|
+
* @returns {Array} Events with timing information
|
|
245
|
+
*/
|
|
246
|
+
function addTimingOffsets(events, timeToConvert, numSteps) {
|
|
247
|
+
const msInHour = 60000 * 60;
|
|
248
|
+
let lastTimeJump = 0;
|
|
249
|
+
|
|
250
|
+
return events.map((event, index) => {
|
|
251
|
+
if (index === 0) {
|
|
252
|
+
event.relativeTimeMs = 0;
|
|
253
|
+
return event;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Calculate base increment for each step
|
|
257
|
+
const baseIncrement = (timeToConvert * msInHour) / numSteps;
|
|
258
|
+
|
|
259
|
+
// Add random fluctuation
|
|
260
|
+
const fluctuation = u.integer(
|
|
261
|
+
-baseIncrement / u.integer(3, 5),
|
|
262
|
+
baseIncrement / u.integer(3, 5)
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Ensure increasing timestamps
|
|
266
|
+
const previousTime = lastTimeJump;
|
|
267
|
+
const currentTime = previousTime + baseIncrement + fluctuation;
|
|
268
|
+
const chosenTime = Math.max(currentTime, previousTime + 1);
|
|
269
|
+
|
|
270
|
+
lastTimeJump = chosenTime;
|
|
271
|
+
event.relativeTimeMs = chosenTime;
|
|
272
|
+
|
|
273
|
+
return event;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Generates actual events with proper timing
|
|
279
|
+
* @param {Context} context - Context object
|
|
280
|
+
* @param {Array} eventsWithTiming - Events with timing information
|
|
281
|
+
* @param {string} distinct_id - User ID
|
|
282
|
+
* @param {number} earliestTime - Base timestamp
|
|
283
|
+
* @param {Array} anonymousIds - Anonymous IDs
|
|
284
|
+
* @param {Array} sessionIds - Session IDs
|
|
285
|
+
* @param {Array} groupKeys - Group keys
|
|
286
|
+
* @returns {Promise<Array>} Generated events
|
|
287
|
+
*/
|
|
288
|
+
async function generateFunnelEvents(
|
|
289
|
+
context,
|
|
290
|
+
eventsWithTiming,
|
|
291
|
+
distinct_id,
|
|
292
|
+
earliestTime,
|
|
293
|
+
anonymousIds,
|
|
294
|
+
sessionIds,
|
|
295
|
+
groupKeys
|
|
296
|
+
) {
|
|
297
|
+
let funnelStartTime;
|
|
298
|
+
|
|
299
|
+
const finalEvents = await Promise.all(eventsWithTiming.map(async (event, index) => {
|
|
300
|
+
const newEvent = await makeEvent(
|
|
301
|
+
context,
|
|
302
|
+
distinct_id,
|
|
303
|
+
earliestTime,
|
|
304
|
+
event,
|
|
305
|
+
anonymousIds,
|
|
306
|
+
sessionIds,
|
|
307
|
+
{},
|
|
308
|
+
groupKeys
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
if (index === 0) {
|
|
312
|
+
funnelStartTime = dayjs(newEvent.time);
|
|
313
|
+
delete newEvent.relativeTimeMs;
|
|
314
|
+
return newEvent;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
newEvent.time = dayjs(funnelStartTime)
|
|
319
|
+
.add(event.relativeTimeMs, "milliseconds")
|
|
320
|
+
.toISOString();
|
|
321
|
+
delete newEvent.relativeTimeMs;
|
|
322
|
+
return newEvent;
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.error("Error setting funnel event time:", e);
|
|
325
|
+
return newEvent;
|
|
326
|
+
}
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
return finalEvents;
|
|
330
|
+
}
|