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