@ak--47/dungeon-master 1.4.5 → 1.5.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/skills/analyze-soup/SKILL.md +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +139 -0
- package/HOOKS.md +1243 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +1 -1
- package/dungeons/technical/anonymous-users.js +1 -1
- package/dungeons/technical/array-of-object-lookup.js +1 -1
- package/dungeons/technical/datagen-v15-verify.js +74 -0
- package/dungeons/technical/experiments.js +1 -1
- package/dungeons/technical/foobar.js +1 -1
- package/dungeons/technical/group-analytics.js +1 -1
- package/dungeons/technical/mirror-strategies.js +1 -1
- package/dungeons/technical/nested-objects.js +1 -1
- package/dungeons/technical/retention-cadence.js +1 -1
- package/dungeons/technical/sanity.js +1 -1
- package/dungeons/technical/scale-test.js +1 -1
- package/dungeons/technical/scd.js +1 -1
- package/dungeons/technical/simple.js +1 -1
- package/dungeons/technical/simplest.js +74 -20
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +4 -0
- package/dungeons/vertical/community.js +9 -3
- package/dungeons/vertical/crypto.js +5 -0
- package/dungeons/vertical/dating.js +23 -10
- package/dungeons/vertical/devtools.js +10 -0
- package/dungeons/vertical/ecommerce.js +6 -0
- package/dungeons/vertical/education.js +11 -0
- package/dungeons/vertical/fintech.js +13 -0
- package/dungeons/vertical/fitness.js +10 -0
- package/dungeons/vertical/food-delivery.js +9 -0
- package/dungeons/vertical/gaming.js +10 -0
- package/dungeons/vertical/healthcare.js +5 -0
- package/dungeons/vertical/insurance-application.js +10 -0
- package/dungeons/vertical/logistics.js +8 -1
- package/dungeons/vertical/marketplace.js +7 -0
- package/dungeons/vertical/media.js +8 -0
- package/dungeons/vertical/real-estate.js +7 -1
- package/dungeons/vertical/sass.js +12 -0
- package/dungeons/vertical/social.js +9 -0
- package/dungeons/vertical/travel.js +5 -0
- package/index.js +19 -4
- package/lib/core/config-validator.js +270 -7
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +72 -1
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/user-loop.js +478 -29
- package/lib/templates/macro-presets.js +39 -9
- package/lib/utils/utils.js +16 -79
- package/lib/verify/counting.js +320 -0
- package/lib/verify/emulate-breakdown.js +512 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +19 -0
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +4 -2
- package/types.d.ts +237 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -1,32 +1,139 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mixpanel breakdown emulator.
|
|
3
3
|
*
|
|
4
|
-
* Best-effort approximation of the table shapes Mixpanel produces for the
|
|
5
|
-
* analyses the Phase 4 hook patterns target. Used by `verify-dungeon`
|
|
6
|
-
* that engineered patterns actually produce the expected ratios
|
|
7
|
-
* by consumers who want to validate dungeons against
|
|
8
|
-
* outside of Mixpanel.
|
|
4
|
+
* Best-effort approximation of the table shapes Mixpanel produces for the
|
|
5
|
+
* five analyses the Phase 4 hook patterns target. Used by `verify-dungeon`
|
|
6
|
+
* to assert that engineered patterns actually produce the expected ratios
|
|
7
|
+
* in the data, AND by consumers who want to validate dungeons against
|
|
8
|
+
* expected business shapes outside of Mixpanel.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* The counting semantics now match Mixpanel's actual implementation
|
|
11
|
+
* (greedy single-pass funnels, distinct-period frequency, null-aware
|
|
12
|
+
* aggregation, attribution touchpoint cap). See `funnel-engine.js` and
|
|
13
|
+
* `counting.js` for the per-rule references into the
|
|
14
|
+
* `mixpanel/analytics` source tree.
|
|
11
15
|
*
|
|
12
16
|
* Caveats:
|
|
13
|
-
* - Mixpanel applies its own per-account UTC offset and time-bucketing
|
|
14
|
-
* emulator uses raw event times
|
|
15
|
-
* -
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
* - This is not bit-exact — it's the *shape* check Phase 4 verification needs.
|
|
17
|
+
* - Mixpanel applies its own per-account UTC offset and time-bucketing
|
|
18
|
+
* rules. This emulator uses raw event times; UTC is assumed.
|
|
19
|
+
* - "Users" are unique `user_id` (falling back to `distinct_id`) found
|
|
20
|
+
* across the events array.
|
|
21
|
+
* - This is not bit-exact — it's the *shape* check verification needs.
|
|
19
22
|
*/
|
|
20
23
|
|
|
24
|
+
import { toMs } from '../hook-helpers/_internal.js';
|
|
25
|
+
import { evaluateFunnel, evaluateAnyOrderCompletion } from './funnel-engine.js';
|
|
26
|
+
import { buildIdentityMap, resolveUserId } from './identity.js';
|
|
27
|
+
import {
|
|
28
|
+
countDistinctPeriods,
|
|
29
|
+
nullAwareAvg,
|
|
30
|
+
nullAwareSum,
|
|
31
|
+
nullAwareExtreme,
|
|
32
|
+
partitionByTimeBucket,
|
|
33
|
+
} from './counting.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* v1.5: pick the right per-user funnel evaluator based on the funnel's `order` mode.
|
|
37
|
+
*
|
|
38
|
+
* - `sequential`, `interrupt`, `interrupted` → greedy single-pass (Mixpanel-aligned)
|
|
39
|
+
* - `first-fixed` → step-0 greedy + any-order on rest
|
|
40
|
+
* - `last-fixed`, `outside-in`, `middle-fixed`, `first-and-last-fixed`
|
|
41
|
+
* → any-order completion (partial verification)
|
|
42
|
+
* - `random` → any-order, informational only
|
|
43
|
+
*
|
|
44
|
+
* Returns a normalized result `{ completed, reached, ttcMs, mode, verificationKind }`.
|
|
45
|
+
* `verificationKind` is `undefined` for sequential modes (full PASS/FAIL allowed),
|
|
46
|
+
* `'partial'` for completion-only modes, `'informational'` for `random`.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<Object>} userEvents
|
|
49
|
+
* @param {string[]} steps
|
|
50
|
+
* @param {Object} options
|
|
51
|
+
* @param {string} [options.funnelOrder='sequential']
|
|
52
|
+
* @param {number} [options.conversionWindowMs]
|
|
53
|
+
* @param {boolean} [options.reentry]
|
|
54
|
+
* @param {Array<Object>} [options.exclusionSteps]
|
|
55
|
+
* @param {boolean | string[]} [options.trackStepProperties]
|
|
56
|
+
* @param {boolean} [options.sessionScoped]
|
|
57
|
+
*/
|
|
58
|
+
function evaluateFunnelByOrder(userEvents, steps, options = {}) {
|
|
59
|
+
const order = options.funnelOrder || 'sequential';
|
|
60
|
+
const sequentialOpts = {
|
|
61
|
+
conversionWindowMs: options.conversionWindowMs,
|
|
62
|
+
reentry: options.reentry,
|
|
63
|
+
exclusionSteps: options.exclusionSteps,
|
|
64
|
+
trackStepProperties: options.trackStepProperties,
|
|
65
|
+
sessionScoped: options.sessionScoped,
|
|
66
|
+
};
|
|
67
|
+
switch (order) {
|
|
68
|
+
case 'sequential':
|
|
69
|
+
case 'interrupt':
|
|
70
|
+
case 'interrupted': {
|
|
71
|
+
const r = /** @type {*} */ (evaluateFunnel(userEvents, steps, sequentialOpts));
|
|
72
|
+
return { ...r, mode: order, verificationKind: undefined };
|
|
73
|
+
}
|
|
74
|
+
case 'first-fixed': {
|
|
75
|
+
const stepZero = /** @type {*} */ (evaluateFunnel(userEvents, [steps[0]], { conversionWindowMs: options.conversionWindowMs }));
|
|
76
|
+
if (!stepZero.completed) {
|
|
77
|
+
return { completed: false, reached: -1, stepEvents: [], stepTimes: [], ttcMs: null, mode: 'first-fixed', verificationKind: 'partial' };
|
|
78
|
+
}
|
|
79
|
+
const rest = evaluateAnyOrderCompletion(userEvents, steps.slice(1));
|
|
80
|
+
const completed = rest.completed;
|
|
81
|
+
const reached = completed ? steps.length - 1 : 0;
|
|
82
|
+
return {
|
|
83
|
+
completed,
|
|
84
|
+
reached,
|
|
85
|
+
stepEvents: [],
|
|
86
|
+
stepTimes: [],
|
|
87
|
+
ttcMs: completed ? rest.completionTimeMs : null,
|
|
88
|
+
mode: 'first-fixed',
|
|
89
|
+
verificationKind: 'partial',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
case 'last-fixed':
|
|
93
|
+
case 'middle-fixed':
|
|
94
|
+
case 'first-and-last-fixed':
|
|
95
|
+
case 'outside-in': {
|
|
96
|
+
const r = evaluateAnyOrderCompletion(userEvents, steps);
|
|
97
|
+
return {
|
|
98
|
+
completed: r.completed,
|
|
99
|
+
reached: r.completed ? steps.length - 1 : -1,
|
|
100
|
+
stepEvents: [],
|
|
101
|
+
stepTimes: [],
|
|
102
|
+
ttcMs: r.completionTimeMs,
|
|
103
|
+
mode: order,
|
|
104
|
+
verificationKind: 'partial',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case 'random': {
|
|
108
|
+
const r = evaluateAnyOrderCompletion(userEvents, steps);
|
|
109
|
+
return {
|
|
110
|
+
completed: r.completed,
|
|
111
|
+
reached: r.completed ? steps.length - 1 : -1,
|
|
112
|
+
stepEvents: [],
|
|
113
|
+
stepTimes: [],
|
|
114
|
+
ttcMs: r.completionTimeMs,
|
|
115
|
+
mode: 'random',
|
|
116
|
+
verificationKind: 'informational',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
default: {
|
|
120
|
+
const r = /** @type {*} */ (evaluateFunnel(userEvents, steps, sequentialOpts));
|
|
121
|
+
return { ...r, mode: order, verificationKind: undefined };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
21
126
|
/**
|
|
22
127
|
* @typedef {Object} EmulateOptions
|
|
23
|
-
* @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'} type
|
|
128
|
+
* @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'|'sessionMetrics'|'retention'} type
|
|
24
129
|
*
|
|
25
130
|
* @property {string} [metricEvent]
|
|
26
131
|
* @property {string} [breakdownByFrequencyOf]
|
|
27
132
|
* @property {boolean} [perUser]
|
|
133
|
+
* @property {('hour'|'day'|'week')} [periodUnit]
|
|
28
134
|
*
|
|
29
135
|
* @property {string[]} [steps]
|
|
136
|
+
* @property {number} [conversionWindowMs]
|
|
30
137
|
*
|
|
31
138
|
* @property {string} [event]
|
|
32
139
|
* @property {string} [property]
|
|
@@ -41,6 +148,27 @@
|
|
|
41
148
|
* @property {string} [attributionEvent]
|
|
42
149
|
* @property {string} [attributionProperty]
|
|
43
150
|
* @property {'firstTouch'|'lastTouch'} [model]
|
|
151
|
+
* @property {number} [touchpointsLimit]
|
|
152
|
+
*
|
|
153
|
+
* v1.5.0 funnel extensions (apply to funnelFrequency + timeToConvert sequential modes):
|
|
154
|
+
* @property {boolean} [reentry]
|
|
155
|
+
* @property {Array<Object>} [exclusionSteps]
|
|
156
|
+
* @property {boolean | string[]} [trackStepProperties]
|
|
157
|
+
* @property {boolean} [sessionScoped]
|
|
158
|
+
*
|
|
159
|
+
* @property {Map<string,string>} [identityMap]
|
|
160
|
+
*
|
|
161
|
+
* Cross-cutting time-bucketed output:
|
|
162
|
+
* @property {('day'|'week'|'month')} [timeBucket]
|
|
163
|
+
* @property {{from: number|string, to: number|string}} [timeBucketRange]
|
|
164
|
+
*
|
|
165
|
+
* **timeBucket result-row contract:**
|
|
166
|
+
* - Buckets WITH events: `{ period, ...originalBreakdownRow }`
|
|
167
|
+
* - Buckets WITHOUT events (only when `timeBucketRange` set):
|
|
168
|
+
* `{ period, _empty: true }` — caller MUST filter `r._empty` before any
|
|
169
|
+
* numerical aggregation. Mixpanel `normal_query.cpp:352-356` emits zero
|
|
170
|
+
* rows for empty intervals; we use the `_empty` marker instead of
|
|
171
|
+
* guessing a per-type zero-row template.
|
|
44
172
|
*/
|
|
45
173
|
|
|
46
174
|
/**
|
|
@@ -54,36 +182,75 @@
|
|
|
54
182
|
export function emulateBreakdown(events, config) {
|
|
55
183
|
if (!Array.isArray(events)) throw new Error('emulateBreakdown: events must be an array');
|
|
56
184
|
if (!config || !config.type) throw new Error('emulateBreakdown: config.type is required');
|
|
185
|
+
|
|
186
|
+
// Auto-build identity map ONCE when profiles supplied. Threads through every
|
|
187
|
+
// breakdown type AND every time-bucket recursive call so pre-auth (device_id
|
|
188
|
+
// only) events resolve to the same canonical user as post-auth (user_id)
|
|
189
|
+
// events. Hoisted above the timeBucket dispatch to avoid rebuilding the
|
|
190
|
+
// map per-bucket on large datasets.
|
|
191
|
+
const identityMap = config.identityMap
|
|
192
|
+
|| (Array.isArray(config.profiles)
|
|
193
|
+
&& config.profiles.some(p =>
|
|
194
|
+
p && ((Array.isArray(p.device_ids) && p.device_ids.length)
|
|
195
|
+
|| (Array.isArray(p.anonymousIds) && p.anonymousIds.length)))
|
|
196
|
+
? buildIdentityMap(config.profiles)
|
|
197
|
+
: undefined);
|
|
198
|
+
|
|
199
|
+
// v1.5: time-bucketed wrapper. Partition events by UTC bucket, run the
|
|
200
|
+
// underlying breakdown per partition, tag rows with `period`.
|
|
201
|
+
//
|
|
202
|
+
// Empty-bucket backfill: when `timeBucketRange: { from, to }` is supplied,
|
|
203
|
+
// every bucket in the range gets a row, even if the breakdown returned no
|
|
204
|
+
// rows. Empty periods emit a single `{ period, _empty: true }` marker so
|
|
205
|
+
// callers can render a continuous trend axis (Mixpanel `normal_query.cpp`
|
|
206
|
+
// emits zero rows for empty intervals). Consumers MUST filter `r._empty`
|
|
207
|
+
// before any aggregation.
|
|
208
|
+
if (config.timeBucket) {
|
|
209
|
+
const range = config.timeBucketRange || {};
|
|
210
|
+
const buckets = partitionByTimeBucket(events, config.timeBucket, range);
|
|
211
|
+
// Pass the pre-built identityMap into recursive calls so the auto-build
|
|
212
|
+
// branch above is a no-op per bucket (would otherwise rebuild N times).
|
|
213
|
+
const inner = { ...config, timeBucket: undefined, timeBucketRange: undefined, identityMap };
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const { period, events: evs } of buckets) {
|
|
216
|
+
const rows = emulateBreakdown(evs, inner);
|
|
217
|
+
if (rows.length) {
|
|
218
|
+
for (const r of rows) out.push({ period, ...r });
|
|
219
|
+
} else {
|
|
220
|
+
out.push({ period, _empty: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const cfg = identityMap ? { ...config, identityMap } : config;
|
|
57
227
|
switch (config.type) {
|
|
58
|
-
case 'frequencyByFrequency': return frequencyByFrequency(events, /** @type {*} */ (
|
|
59
|
-
case 'funnelFrequency': return funnelFrequency(events, /** @type {*} */ (
|
|
60
|
-
case 'aggregatePerUser': return aggregatePerUser(events, /** @type {*} */ (
|
|
61
|
-
case 'timeToConvert': return timeToConvert(events, /** @type {*} */ (
|
|
62
|
-
case 'attributedBy': return attributedBy(events, /** @type {*} */ (
|
|
228
|
+
case 'frequencyByFrequency': return frequencyByFrequency(events, /** @type {*} */ (cfg));
|
|
229
|
+
case 'funnelFrequency': return funnelFrequency(events, /** @type {*} */ (cfg));
|
|
230
|
+
case 'aggregatePerUser': return aggregatePerUser(events, /** @type {*} */ (cfg));
|
|
231
|
+
case 'timeToConvert': return timeToConvert(events, /** @type {*} */ (cfg));
|
|
232
|
+
case 'attributedBy': return attributedBy(events, /** @type {*} */ (cfg));
|
|
233
|
+
case 'sessionMetrics': return sessionMetrics(events, /** @type {*} */ (cfg));
|
|
234
|
+
case 'retention': return retention(events, /** @type {*} */ (cfg));
|
|
63
235
|
default: throw new Error(`emulateBreakdown: unknown type "${config.type}"`);
|
|
64
236
|
}
|
|
65
237
|
}
|
|
66
238
|
|
|
67
239
|
// ── Frequency × Frequency (Insights, Frequency Distribution by per-user count of B) ──
|
|
240
|
+
//
|
|
241
|
+
// Both axes are DISTINCT PERIOD counts (default: days), not raw event counts.
|
|
242
|
+
// Reference: addiction_query.cpp — see counting.js#countDistinctPeriods for
|
|
243
|
+
// the rule and why it matters.
|
|
68
244
|
|
|
69
|
-
function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf }) {
|
|
245
|
+
function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf, periodUnit = 'day', identityMap }) {
|
|
70
246
|
if (!metricEvent || !breakdownByFrequencyOf) {
|
|
71
247
|
throw new Error('frequencyByFrequency requires metricEvent and breakdownByFrequencyOf');
|
|
72
248
|
}
|
|
73
|
-
const
|
|
74
|
-
const userBreakdown = new Map();
|
|
75
|
-
const uids = new Set();
|
|
76
|
-
for (const ev of events) {
|
|
77
|
-
const uid = userIdOf(ev);
|
|
78
|
-
if (!uid) continue;
|
|
79
|
-
uids.add(uid);
|
|
80
|
-
if (ev.event === metricEvent) userMetric.set(uid, (userMetric.get(uid) || 0) + 1);
|
|
81
|
-
if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
|
|
82
|
-
}
|
|
249
|
+
const userEvents = groupByUser(events, identityMap);
|
|
83
250
|
const cell = new Map(); // `${m}|${b}` → user_count
|
|
84
|
-
for (const
|
|
85
|
-
const m =
|
|
86
|
-
const b =
|
|
251
|
+
for (const [, evs] of userEvents) {
|
|
252
|
+
const m = countDistinctPeriods(evs, metricEvent, /** @type {*} */ (periodUnit));
|
|
253
|
+
const b = countDistinctPeriods(evs, breakdownByFrequencyOf, /** @type {*} */ (periodUnit));
|
|
87
254
|
const key = `${m}|${b}`;
|
|
88
255
|
cell.set(key, (cell.get(key) || 0) + 1);
|
|
89
256
|
}
|
|
@@ -94,36 +261,30 @@ function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf }) {
|
|
|
94
261
|
}
|
|
95
262
|
|
|
96
263
|
// ── Funnel Frequency Breakdown (Funnel report broken down by per-user count of X) ──
|
|
264
|
+
//
|
|
265
|
+
// Step progression uses the greedy single-pass funnel engine
|
|
266
|
+
// (funnel-engine.js → history.cpp). Cohort breakdown axis uses distinct-period
|
|
267
|
+
// counting (addiction_query.cpp).
|
|
97
268
|
|
|
98
|
-
function funnelFrequency(events, { steps, breakdownByFrequencyOf }) {
|
|
269
|
+
function funnelFrequency(events, { steps, breakdownByFrequencyOf, conversionWindowMs, periodUnit = 'day', funnelOrder = 'sequential', identityMap, reentry, exclusionSteps, trackStepProperties, sessionScoped }) {
|
|
99
270
|
if (!Array.isArray(steps) || !steps.length) throw new Error('funnelFrequency requires steps[]');
|
|
100
271
|
if (!breakdownByFrequencyOf) throw new Error('funnelFrequency requires breakdownByFrequencyOf');
|
|
101
|
-
const userEvents = groupByUser(events);
|
|
102
|
-
const userBreakdown = new Map();
|
|
103
|
-
for (const [uid, evs] of userEvents) {
|
|
104
|
-
const c = evs.filter(e => e && e.event === breakdownByFrequencyOf).length;
|
|
105
|
-
userBreakdown.set(uid, c);
|
|
106
|
-
}
|
|
272
|
+
const userEvents = groupByUser(events, identityMap);
|
|
107
273
|
const result = [];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (stepIdx > s) break;
|
|
117
|
-
}
|
|
118
|
-
if (stepIdx > s) {
|
|
119
|
-
const b = userBreakdown.get(uid) || 0;
|
|
120
|
-
conversions.set(b, (conversions.get(b) || 0) + 1);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
for (const [b, c] of conversions) {
|
|
124
|
-
result.push({ step: stepName, step_index: s, breakdown_freq: b, conversions: c, conversion_pct: 0 });
|
|
274
|
+
const conversions = new Map(); // `${stepIdx}|${b}` → count
|
|
275
|
+
for (const [, evs] of userEvents) {
|
|
276
|
+
// v1.5: dispatch on funnel.order so non-sequential modes don't return 0% trivially.
|
|
277
|
+
const r = evaluateFunnelByOrder(evs, steps, { conversionWindowMs, funnelOrder, reentry, exclusionSteps, trackStepProperties, sessionScoped });
|
|
278
|
+
const b = countDistinctPeriods(evs, breakdownByFrequencyOf, /** @type {*} */ (periodUnit));
|
|
279
|
+
for (let s = 0; s <= r.reached; s++) {
|
|
280
|
+
const key = `${s}|${b}`;
|
|
281
|
+
conversions.set(key, (conversions.get(key) || 0) + 1);
|
|
125
282
|
}
|
|
126
283
|
}
|
|
284
|
+
for (const [key, count] of conversions) {
|
|
285
|
+
const [s, b] = key.split('|').map(Number);
|
|
286
|
+
result.push({ step: steps[s], step_index: s, breakdown_freq: b, conversions: count, conversion_pct: 0 });
|
|
287
|
+
}
|
|
127
288
|
// Conversion % at each step relative to its own breakdown_freq's step-0 baseline.
|
|
128
289
|
const baseline = new Map();
|
|
129
290
|
for (const r of result) {
|
|
@@ -137,49 +298,74 @@ function funnelFrequency(events, { steps, breakdownByFrequencyOf }) {
|
|
|
137
298
|
}
|
|
138
299
|
|
|
139
300
|
// ── Aggregate per user (Insights, sum/avg of property X by per-user count of B) ──
|
|
301
|
+
//
|
|
302
|
+
// AVG/SUM/MIN/MAX use null-aware aggregation (normal_query.cpp). The cohort
|
|
303
|
+
// breakdown axis uses distinct-period counting.
|
|
140
304
|
|
|
141
|
-
function aggregatePerUser(events, { event, property, agg = 'avg', breakdownByFrequencyOf }) {
|
|
305
|
+
function aggregatePerUser(events, { event, property, agg = 'avg', breakdownByFrequencyOf, periodUnit = 'day', identityMap }) {
|
|
142
306
|
if (!event) throw new Error('aggregatePerUser requires event');
|
|
143
307
|
if (!breakdownByFrequencyOf) throw new Error('aggregatePerUser requires breakdownByFrequencyOf');
|
|
144
308
|
if (agg !== 'count' && !property) throw new Error('aggregatePerUser requires property unless agg is "count"');
|
|
145
|
-
const
|
|
309
|
+
const userEvents = groupByUser(events, identityMap);
|
|
310
|
+
const userAgg = new Map();
|
|
146
311
|
const userBreakdown = new Map();
|
|
147
|
-
for (const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
312
|
+
for (const [uid, evs] of userEvents) {
|
|
313
|
+
const matches = evs.filter(e => e && e.event === event);
|
|
314
|
+
let aggValue;
|
|
315
|
+
if (agg === 'count') {
|
|
316
|
+
aggValue = matches.length;
|
|
317
|
+
} else {
|
|
318
|
+
const values = matches.map(e => e[property]);
|
|
319
|
+
aggValue = applyNullAwareAgg(values, agg);
|
|
320
|
+
}
|
|
321
|
+
// Skip users with no aggregate (no matching events for count==0 still
|
|
322
|
+
// counted; numeric agg returning null means no numeric values).
|
|
323
|
+
if (aggValue !== null && aggValue !== undefined) {
|
|
324
|
+
userAgg.set(uid, aggValue);
|
|
160
325
|
}
|
|
161
|
-
|
|
326
|
+
userBreakdown.set(uid, countDistinctPeriods(evs, breakdownByFrequencyOf, /** @type {*} */ (periodUnit)));
|
|
162
327
|
}
|
|
163
|
-
const userAgg = new Map();
|
|
164
|
-
for (const [uid, vals] of userVals) userAgg.set(uid, applyAgg(vals, agg));
|
|
165
328
|
const buckets = new Map(); // breakdown_freq → [aggregates]
|
|
166
329
|
for (const [uid, v] of userAgg) {
|
|
167
330
|
const b = userBreakdown.get(uid) || 0;
|
|
168
331
|
if (!buckets.has(b)) buckets.set(b, []);
|
|
169
332
|
buckets.get(b).push(v);
|
|
170
333
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
334
|
+
// Cohort-level rollup: Mixpanel "Aggregate per user" report applies the
|
|
335
|
+
// SAME `agg` mode at the cohort level (e.g. SUM-mode shows sum-of-sums,
|
|
336
|
+
// MAX-mode shows max-of-maxes). We expose all of them so consumers can
|
|
337
|
+
// pick the column that matches the report they're verifying:
|
|
338
|
+
// - `avg_aggregate` — mean of per-user aggregates (always available)
|
|
339
|
+
// - `cohort_sum` / `cohort_min` / `cohort_max` — same `agg` applied across users
|
|
340
|
+
return [...buckets.entries()].map(([b, vs]) => {
|
|
341
|
+
const sum = vs.reduce((a, x) => a + x, 0);
|
|
342
|
+
const row = {
|
|
343
|
+
breakdown_freq: b,
|
|
344
|
+
user_count: vs.length,
|
|
345
|
+
avg_aggregate: sum / vs.length,
|
|
346
|
+
};
|
|
347
|
+
if (agg === 'sum' || agg === 'count') {
|
|
348
|
+
row.cohort_sum = sum;
|
|
349
|
+
} else if (agg === 'min') {
|
|
350
|
+
row.cohort_min = vs.reduce((a, x) => x < a ? x : a, Infinity);
|
|
351
|
+
} else if (agg === 'max') {
|
|
352
|
+
row.cohort_max = vs.reduce((a, x) => x > a ? x : a, -Infinity);
|
|
353
|
+
}
|
|
354
|
+
return row;
|
|
355
|
+
}).sort((x, y) => x.breakdown_freq - y.breakdown_freq);
|
|
176
356
|
}
|
|
177
357
|
|
|
178
358
|
// ── Time to Convert (Funnel TTC, broken down by user property) ──
|
|
359
|
+
//
|
|
360
|
+
// Step pair matched via the greedy funnel engine (history.cpp). When the
|
|
361
|
+
// funnel completes via the engine, ttcMs = stepTimes[1] - stepTimes[0].
|
|
362
|
+
// Differs from the old "first occurrence of fromEvent then first occurrence
|
|
363
|
+
// of toEvent after it" logic by enforcing the same temporal rules Mixpanel
|
|
364
|
+
// uses for funnel matching.
|
|
179
365
|
|
|
180
|
-
function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, profiles = [] }) {
|
|
366
|
+
function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, profiles = [], funnelOrder = 'sequential', conversionWindowMs, identityMap, reentry, exclusionSteps, sessionScoped }) {
|
|
181
367
|
if (!fromEvent || !toEvent) throw new Error('timeToConvert requires fromEvent and toEvent');
|
|
182
|
-
const userEvents = groupByUser(events);
|
|
368
|
+
const userEvents = groupByUser(events, identityMap);
|
|
183
369
|
const profileByUid = new Map();
|
|
184
370
|
for (const p of profiles) {
|
|
185
371
|
if (!p) continue;
|
|
@@ -188,20 +374,16 @@ function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, pr
|
|
|
188
374
|
}
|
|
189
375
|
const buckets = new Map(); // segValue → [ttcMs]
|
|
190
376
|
for (const [uid, evs] of userEvents) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const b = sorted.slice(aIdx + 1).find(e => e && e.event === toEvent);
|
|
196
|
-
if (!b) continue;
|
|
197
|
-
const ttcMs = toMs(b.time) - toMs(a.time);
|
|
198
|
-
if (!Number.isFinite(ttcMs) || ttcMs < 0) continue;
|
|
377
|
+
// v1.5: respect funnel.order. For random mode, ttcMs is informational
|
|
378
|
+
// (lastSeenTime - firstSeenTime), not Mixpanel TTC.
|
|
379
|
+
const r = evaluateFunnelByOrder(evs, [fromEvent, toEvent], { funnelOrder, conversionWindowMs, reentry, exclusionSteps, sessionScoped });
|
|
380
|
+
if (!r.completed || r.ttcMs === null || !Number.isFinite(r.ttcMs) || r.ttcMs < 0) continue;
|
|
199
381
|
const profile = profileByUid.get(uid);
|
|
200
382
|
const segValue = breakdownByUserProperty
|
|
201
383
|
? (profile ? (profile[breakdownByUserProperty] ?? 'unknown') : 'unknown')
|
|
202
384
|
: 'all';
|
|
203
385
|
if (!buckets.has(segValue)) buckets.set(segValue, []);
|
|
204
|
-
buckets.get(segValue).push(ttcMs);
|
|
386
|
+
buckets.get(segValue).push(r.ttcMs);
|
|
205
387
|
}
|
|
206
388
|
return [...buckets.entries()].map(([seg, ttcs]) => ({
|
|
207
389
|
segment_value: seg,
|
|
@@ -212,22 +394,37 @@ function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, pr
|
|
|
212
394
|
}
|
|
213
395
|
|
|
214
396
|
// ── Attributed By (first-/last-touch attribution by event property value) ──
|
|
397
|
+
//
|
|
398
|
+
// Touchpoint cap: max 10 touchpoints in lookback window
|
|
399
|
+
// (`TOUCHPOINTS_LIMIT = 10` in attributed_value_reader.cpp). For first/last
|
|
400
|
+
// touch the cap matters when the user has > 10 touches before conversion;
|
|
401
|
+
// the cap shifts which touches enter the candidate pool.
|
|
215
402
|
|
|
216
|
-
function attributedBy(events, {
|
|
403
|
+
function attributedBy(events, {
|
|
404
|
+
conversionEvent,
|
|
405
|
+
attributionEvent,
|
|
406
|
+
attributionProperty,
|
|
407
|
+
model = 'firstTouch',
|
|
408
|
+
touchpointsLimit = 10,
|
|
409
|
+
identityMap,
|
|
410
|
+
}) {
|
|
217
411
|
if (!conversionEvent || !attributionEvent || !attributionProperty) {
|
|
218
412
|
throw new Error('attributedBy requires conversionEvent, attributionEvent, attributionProperty');
|
|
219
413
|
}
|
|
220
|
-
const userEvents = groupByUser(events);
|
|
414
|
+
const userEvents = groupByUser(events, identityMap);
|
|
221
415
|
const counts = new Map();
|
|
222
|
-
for (const [
|
|
416
|
+
for (const [, evs] of userEvents) {
|
|
223
417
|
const sorted = sortByTime(evs);
|
|
224
418
|
const conversion = sorted.find(e => e && e.event === conversionEvent);
|
|
225
419
|
if (!conversion) continue;
|
|
226
420
|
const conversionTime = toMs(conversion.time);
|
|
227
|
-
const
|
|
421
|
+
const allTouches = sorted.filter(e =>
|
|
228
422
|
e && e.event === attributionEvent && toMs(e.time) <= conversionTime
|
|
229
423
|
);
|
|
230
|
-
if (!
|
|
424
|
+
if (!allTouches.length) continue;
|
|
425
|
+
// Cap to the last `touchpointsLimit` touches in the lookback window.
|
|
426
|
+
// (When touch count <= cap, this is a no-op.)
|
|
427
|
+
const touches = allTouches.slice(-touchpointsLimit);
|
|
231
428
|
const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
|
|
232
429
|
const v = touch[attributionProperty] ?? 'unknown';
|
|
233
430
|
counts.set(v, (counts.get(v) || 0) + 1);
|
|
@@ -238,16 +435,211 @@ function attributedBy(events, { conversionEvent, attributionEvent, attributionPr
|
|
|
238
435
|
})).sort((a, b) => b.conversions - a.conversions);
|
|
239
436
|
}
|
|
240
437
|
|
|
438
|
+
// ── Retention (birth-anchored day buckets) ───────────────────────────────────
|
|
439
|
+
//
|
|
440
|
+
// Reference: backend/arb/reader/queries/retention_query.cpp
|
|
441
|
+
//
|
|
442
|
+
// Bucketing rule (retention_query.cpp:1227-1231):
|
|
443
|
+
// time_to_retention_event_s = retention_event_time_s - first_event_time_s
|
|
444
|
+
// bucket = floor(time_to_retention_event_s / bucket_seconds)
|
|
445
|
+
//
|
|
446
|
+
// We compute bucket = floor((return_ms - birth_ms) / DAY_MS) — a raw ms-delta
|
|
447
|
+
// from birth, NOT a UTC-calendar-day-number difference. So a return 23h after
|
|
448
|
+
// birth lands in bucket 0; a return 25h after birth lands in bucket 1.
|
|
449
|
+
//
|
|
450
|
+
// Birth-can-retain (default false; retention_query.cpp:1097-1109):
|
|
451
|
+
// if (birth_can_retain) return first_event_time_ms <= retention_event_time_ms;
|
|
452
|
+
// else return first_event_time_ms < retention_event_time_ms;
|
|
453
|
+
// We default to false (return events strictly after birth, ms-precise).
|
|
454
|
+
//
|
|
455
|
+
// Optional `carry_forward`: once retained on day M, count as retained on
|
|
456
|
+
// every later bucket (Mixpanel's CARRY_FORWARD unbounded mode —
|
|
457
|
+
// retention_query.cpp:1824-1837).
|
|
458
|
+
//
|
|
459
|
+
// Optional `segmentBy`: partition the cohort by the birth event's property
|
|
460
|
+
// value (Mixpanel's segment_event=FIRST mode — retention_query.cpp:1309).
|
|
461
|
+
//
|
|
462
|
+
// NOT IMPLEMENTED — these are MORE common than initially documented; treat as
|
|
463
|
+
// known scope gaps:
|
|
464
|
+
// - COMPOUNDED retention (retention_query.cpp:670) reuses the first-event
|
|
465
|
+
// filter as the return filter, making EVERY cohort event a retention
|
|
466
|
+
// candidate. Used heavily in Mixpanel's "DAU coming back" reports.
|
|
467
|
+
// - CARRY_BACK / CONSECUTIVE_FORWARD unbounded modes
|
|
468
|
+
// - CALENDAR_START bucket alignment (retention_query.cpp:308-321)
|
|
469
|
+
// - segment_event=SECOND (retention_query.cpp:1310 — return event property)
|
|
470
|
+
// - Cohort window (only users with birth in `from_date..to_date` are in
|
|
471
|
+
// cohort; we use ALL users with the birth event in the dataset)
|
|
472
|
+
// - week / month bucket units (only `day` here)
|
|
473
|
+
|
|
474
|
+
const DAY_MS_RET = 86400 * 1000;
|
|
475
|
+
|
|
476
|
+
function retention(events, { cohortEvent, returnEvent, dayBuckets = [1, 7, 14, 30], segmentBy, carry_forward = false, birthCanRetain = false, identityMap }) {
|
|
477
|
+
if (!cohortEvent) throw new Error('retention requires cohortEvent');
|
|
478
|
+
if (!returnEvent) throw new Error('retention requires returnEvent');
|
|
479
|
+
if (!Array.isArray(dayBuckets) || !dayBuckets.length) {
|
|
480
|
+
throw new Error('retention requires non-empty dayBuckets');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const userEvents = groupByUser(events, identityMap);
|
|
484
|
+
|
|
485
|
+
// segment → cohort users + per-user state
|
|
486
|
+
const cohorts = new Map();
|
|
487
|
+
const ensureSegment = (seg) => {
|
|
488
|
+
if (!cohorts.has(seg)) cohorts.set(seg, { users: new Set(), birthMsByUser: new Map(), returnBucketsByUser: new Map() });
|
|
489
|
+
return cohorts.get(seg);
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
for (const [uid, evs] of userEvents) {
|
|
493
|
+
// Birth = earliest cohortEvent for this user.
|
|
494
|
+
const sorted = sortByTime(evs);
|
|
495
|
+
const birth = sorted.find(e => e.event === cohortEvent);
|
|
496
|
+
if (!birth) continue;
|
|
497
|
+
const birthMs = toMs(birth.time);
|
|
498
|
+
if (!Number.isFinite(birthMs)) continue;
|
|
499
|
+
const seg = segmentBy ? (birth[segmentBy] ?? 'unknown') : 'all';
|
|
500
|
+
const sb = ensureSegment(seg);
|
|
501
|
+
sb.users.add(uid);
|
|
502
|
+
sb.birthMsByUser.set(uid, birthMs);
|
|
503
|
+
|
|
504
|
+
const retBuckets = new Set();
|
|
505
|
+
for (const ev of sorted) {
|
|
506
|
+
if (ev.event !== returnEvent) continue;
|
|
507
|
+
const evMs = toMs(ev.time);
|
|
508
|
+
if (!Number.isFinite(evMs)) continue;
|
|
509
|
+
// Mixpanel ms-strict gate (retention_query.cpp:1097-1109).
|
|
510
|
+
const passes = birthCanRetain ? (birthMs <= evMs) : (birthMs < evMs);
|
|
511
|
+
if (!passes) continue;
|
|
512
|
+
// Bucket by ms-delta — Mixpanel time_to_retention_event_s / bucket_seconds.
|
|
513
|
+
const bucket = Math.floor((evMs - birthMs) / DAY_MS_RET);
|
|
514
|
+
if (bucket >= 0) retBuckets.add(bucket);
|
|
515
|
+
}
|
|
516
|
+
sb.returnBucketsByUser.set(uid, retBuckets);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const out = [];
|
|
520
|
+
for (const [seg, sb] of cohorts) {
|
|
521
|
+
const cohortSize = sb.users.size;
|
|
522
|
+
for (const day of dayBuckets) {
|
|
523
|
+
let retained = 0;
|
|
524
|
+
for (const uid of sb.users) {
|
|
525
|
+
const buckets = sb.returnBucketsByUser.get(uid);
|
|
526
|
+
if (!buckets) continue;
|
|
527
|
+
if (carry_forward) {
|
|
528
|
+
// Retained on bucket N if hit any bucket in [0, N] (or [1, N] if you exclude bucket 0).
|
|
529
|
+
let hit = false;
|
|
530
|
+
for (const b of buckets) {
|
|
531
|
+
if (b <= day) { hit = true; break; }
|
|
532
|
+
}
|
|
533
|
+
if (hit) retained++;
|
|
534
|
+
} else {
|
|
535
|
+
if (buckets.has(day)) retained++;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
out.push({
|
|
539
|
+
day,
|
|
540
|
+
retained_count: retained,
|
|
541
|
+
cohort_size: cohortSize,
|
|
542
|
+
retained_pct: cohortSize ? retained / cohortSize : 0,
|
|
543
|
+
segment: seg,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
out.sort((a, b) => String(a.segment).localeCompare(String(b.segment)) || a.day - b.day);
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Session Metrics (Mixpanel session report) ────────────────────────────────
|
|
552
|
+
//
|
|
553
|
+
// Reference: backend/arb/reader/queries/session_query.cpp.
|
|
554
|
+
//
|
|
555
|
+
// Mixpanel computes sessions at query time using a 30-min gap (default) +
|
|
556
|
+
// 24h max model and emits synthetic event properties: $duration_s,
|
|
557
|
+
// $event_count, $origin_start, $origin_end. Our generator pre-stamps
|
|
558
|
+
// `session_id` using the same rules; here we trust that stamping and just
|
|
559
|
+
// group → aggregate per session.
|
|
560
|
+
//
|
|
561
|
+
// Returns an array with one row per requested metric:
|
|
562
|
+
// [{ metric: 'count', avg, median, p90, total_sessions }]
|
|
563
|
+
// [{ metric: 'duration', avg_ms, median_ms, p90_ms, total_sessions }]
|
|
564
|
+
// [{ metric: 'eventsPerSession',avg, median, p90, total_sessions }]
|
|
565
|
+
|
|
566
|
+
function sessionMetrics(events, { event, metrics = ['count', 'duration', 'eventsPerSession'], identityMap }) {
|
|
567
|
+
const userEvents = groupByUser(events, identityMap);
|
|
568
|
+
const sessionsByUser = new Map();
|
|
569
|
+
for (const [uid, evs] of userEvents) {
|
|
570
|
+
// One session bucket per (user, session_id). Events without session_id
|
|
571
|
+
// are excluded — Mixpanel only emits session reports for events that
|
|
572
|
+
// landed inside an evaluated session.
|
|
573
|
+
const buckets = new Map();
|
|
574
|
+
for (const ev of evs) {
|
|
575
|
+
if (ev.session_id == null) continue;
|
|
576
|
+
const sid = String(ev.session_id);
|
|
577
|
+
if (!buckets.has(sid)) buckets.set(sid, []);
|
|
578
|
+
buckets.get(sid).push(ev);
|
|
579
|
+
}
|
|
580
|
+
// Optional event filter: only sessions containing this event qualify.
|
|
581
|
+
if (event) {
|
|
582
|
+
for (const [sid, evs2] of [...buckets]) {
|
|
583
|
+
if (!evs2.some(e => e.event === event)) buckets.delete(sid);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (buckets.size) sessionsByUser.set(uid, buckets);
|
|
587
|
+
}
|
|
588
|
+
const allSessions = []; // { duration_ms, event_count }
|
|
589
|
+
const sessionCountsPerUser = [];
|
|
590
|
+
for (const [, buckets] of sessionsByUser) {
|
|
591
|
+
sessionCountsPerUser.push(buckets.size);
|
|
592
|
+
for (const [, evs] of buckets) {
|
|
593
|
+
const sorted = sortByTime(evs);
|
|
594
|
+
const start = toMs(sorted[0].time);
|
|
595
|
+
const end = toMs(sorted[sorted.length - 1].time);
|
|
596
|
+
allSessions.push({ duration_ms: end - start, event_count: sorted.length });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const out = [];
|
|
600
|
+
const requested = new Set(metrics);
|
|
601
|
+
if (requested.has('count')) {
|
|
602
|
+
out.push({
|
|
603
|
+
metric: 'count',
|
|
604
|
+
avg: avg(sessionCountsPerUser),
|
|
605
|
+
median: median(sessionCountsPerUser),
|
|
606
|
+
p90: percentile(sessionCountsPerUser, 0.9),
|
|
607
|
+
total_sessions: allSessions.length,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
if (requested.has('duration')) {
|
|
611
|
+
const durations = allSessions.map(s => s.duration_ms);
|
|
612
|
+
out.push({
|
|
613
|
+
metric: 'duration',
|
|
614
|
+
avg_ms: avg(durations),
|
|
615
|
+
median_ms: median(durations),
|
|
616
|
+
p90_ms: percentile(durations, 0.9),
|
|
617
|
+
total_sessions: allSessions.length,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
if (requested.has('eventsPerSession')) {
|
|
621
|
+
const eventCounts = allSessions.map(s => s.event_count);
|
|
622
|
+
out.push({
|
|
623
|
+
metric: 'eventsPerSession',
|
|
624
|
+
avg: avg(eventCounts),
|
|
625
|
+
median: median(eventCounts),
|
|
626
|
+
p90: percentile(eventCounts, 0.9),
|
|
627
|
+
total_sessions: allSessions.length,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
|
|
241
633
|
// ── shared helpers ──
|
|
242
634
|
|
|
243
|
-
function userIdOf(ev) {
|
|
244
|
-
return
|
|
635
|
+
function userIdOf(ev, identityMap) {
|
|
636
|
+
return resolveUserId(ev, identityMap);
|
|
245
637
|
}
|
|
246
638
|
|
|
247
|
-
function groupByUser(events) {
|
|
639
|
+
function groupByUser(events, identityMap) {
|
|
248
640
|
const userEvents = new Map();
|
|
249
641
|
for (const ev of events) {
|
|
250
|
-
const uid = userIdOf(ev);
|
|
642
|
+
const uid = userIdOf(ev, identityMap);
|
|
251
643
|
if (!uid) continue;
|
|
252
644
|
if (!userEvents.has(uid)) userEvents.set(uid, []);
|
|
253
645
|
userEvents.get(uid).push(ev);
|
|
@@ -259,17 +651,13 @@ function sortByTime(evs) {
|
|
|
259
651
|
return evs.slice().sort((a, b) => toMs(a && a.time) - toMs(b && b.time));
|
|
260
652
|
}
|
|
261
653
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
function applyAgg(vals, agg) {
|
|
265
|
-
if (!vals || !vals.length) return 0;
|
|
654
|
+
function applyNullAwareAgg(values, agg) {
|
|
266
655
|
switch (agg) {
|
|
267
|
-
case 'sum': return
|
|
268
|
-
case '
|
|
269
|
-
case '
|
|
270
|
-
case 'min': return Math.min(...vals);
|
|
656
|
+
case 'sum': return nullAwareSum(values);
|
|
657
|
+
case 'max': return nullAwareExtreme(values, 'max');
|
|
658
|
+
case 'min': return nullAwareExtreme(values, 'min');
|
|
271
659
|
case 'avg':
|
|
272
|
-
default: return
|
|
660
|
+
default: return nullAwareAvg(values);
|
|
273
661
|
}
|
|
274
662
|
}
|
|
275
663
|
|
|
@@ -279,3 +667,19 @@ function median(arr) {
|
|
|
279
667
|
const mid = Math.floor(sorted.length / 2);
|
|
280
668
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
281
669
|
}
|
|
670
|
+
|
|
671
|
+
function avg(arr) {
|
|
672
|
+
if (!arr.length) return 0;
|
|
673
|
+
return arr.reduce((a, x) => a + x, 0) / arr.length;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function percentile(arr, p) {
|
|
677
|
+
if (!arr.length) return 0;
|
|
678
|
+
const sorted = arr.slice().sort((a, b) => a - b);
|
|
679
|
+
// Linear interpolation method (consistent with d3.quantile).
|
|
680
|
+
const idx = (sorted.length - 1) * p;
|
|
681
|
+
const lo = Math.floor(idx);
|
|
682
|
+
const hi = Math.ceil(idx);
|
|
683
|
+
if (lo === hi) return sorted[lo];
|
|
684
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
|
685
|
+
}
|