@ak--47/dungeon-master 1.5.4 → 1.6.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 +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
package/lib/verify/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @ak--47/dungeon-master/verify — Phase 4 verification surface.
|
|
3
3
|
*
|
|
4
|
-
* `emulateBreakdown` produces the table shapes Mixpanel shows for the
|
|
4
|
+
* `emulateBreakdown` produces the table shapes Mixpanel shows for the
|
|
5
5
|
* supported analyses (frequencyByFrequency, funnelFrequency, aggregatePerUser,
|
|
6
|
-
* timeToConvert, attributedBy
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* timeToConvert, attributedBy, sessionMetrics, retention, distinctCount,
|
|
7
|
+
* eventBreakdown, uniques, lifecycle, topPaths). `verifyDungeon` is the
|
|
8
|
+
* higher-level wrapper that runs a dungeon and asserts emulator outputs match
|
|
9
|
+
* expected ratios — wire this into your CI to catch dungeon drift.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
export { emulateBreakdown } from './emulate-breakdown.js';
|
|
@@ -16,6 +17,7 @@ export {
|
|
|
16
17
|
evaluateFunnelHPC,
|
|
17
18
|
resolveFunnelSegment,
|
|
18
19
|
normalizeStep,
|
|
20
|
+
normalizeFunnelSteps,
|
|
19
21
|
matchesStepFilter,
|
|
20
22
|
timestampComesAfter,
|
|
21
23
|
withinConversionWindow,
|
|
@@ -25,9 +27,35 @@ export { buildIdentityMap, resolveUserId } from './identity.js';
|
|
|
25
27
|
export {
|
|
26
28
|
countDistinctPeriods,
|
|
27
29
|
countDistinctValues,
|
|
30
|
+
countEvents,
|
|
28
31
|
nullAwareAvg,
|
|
29
32
|
nullAwareSum,
|
|
30
33
|
nullAwareExtreme,
|
|
31
34
|
binByDistinctPeriods,
|
|
32
35
|
partitionByTimeBucket,
|
|
36
|
+
frequencyHistogram,
|
|
33
37
|
} from './counting.js';
|
|
38
|
+
export { extractFlows, aggregateFlows, UNCOMMON_FLOWS_EVENT } from './flows.js';
|
|
39
|
+
export {
|
|
40
|
+
coerceToBreakdownKey,
|
|
41
|
+
breakdownSegmentKey,
|
|
42
|
+
filterEquals,
|
|
43
|
+
filterCompare,
|
|
44
|
+
filterContains,
|
|
45
|
+
matchesWhere,
|
|
46
|
+
} from './coerce.js';
|
|
47
|
+
export { evaluateFormula } from './formula.js';
|
|
48
|
+
export { filterFirstTimeEver } from './first-time.js';
|
|
49
|
+
export { sessionize, sessionOrdinals, SESSION_COPY_PROPERTIES } from './sessionize.js';
|
|
50
|
+
export { applyFunnelDefaults } from './verify-dungeon.js';
|
|
51
|
+
export {
|
|
52
|
+
STORY_ARCHETYPES,
|
|
53
|
+
VERDICT_RANK,
|
|
54
|
+
parseMetric,
|
|
55
|
+
selectRows,
|
|
56
|
+
verdictFor,
|
|
57
|
+
evaluateAssertion,
|
|
58
|
+
validateStories,
|
|
59
|
+
storiesToChecks,
|
|
60
|
+
evaluateStories,
|
|
61
|
+
} from './story-runner.js';
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-time sessionization — Mixpanel's session pre-pass, re-derived.
|
|
3
|
+
*
|
|
4
|
+
* Mixpanel sessions are computed at QUERY time from raw event timestamps and
|
|
5
|
+
* emitted as synthetic $session_start/$session_end events; they are never
|
|
6
|
+
* stored (backend/arb/reader/queries/session_query.cpp; sessions.go). The
|
|
7
|
+
* verifier re-derives them the same way rather than trusting any
|
|
8
|
+
* generator-stamped `session_id`.
|
|
9
|
+
*
|
|
10
|
+
* ARB rules implemented (all from session_query.cpp, TIMEOUT trigger mode):
|
|
11
|
+
* - Three split triggers (the timeout branch of
|
|
12
|
+
* session_query_params_process_event):
|
|
13
|
+
* 1. gap: time_ms − last_event_time_ms > session_timeout_ms (strict >)
|
|
14
|
+
* 2. max: time_ms > first_event_time_ms + session_max_time_ms
|
|
15
|
+
* (strict >, anchored to the session's FIRST event)
|
|
16
|
+
* 3. UTC day-index change: floor(ms / DAY) differs from the previous
|
|
17
|
+
* event's ("an index to uniquely identify each day. this is used to
|
|
18
|
+
* ensure sessions terminate per day"). We are UTC-everywhere; the
|
|
19
|
+
* project-timezone projection (time_project_ms_to_query_ms) is the
|
|
20
|
+
* qtz seam — findings #10, deferred.
|
|
21
|
+
* A trigger disabled by 0/undefined follows ARB's `> 0` guards; the day
|
|
22
|
+
* trigger is unconditional. NOTE: with the day trigger active,
|
|
23
|
+
* maxSessionMs only ever fires when configured BELOW 24h — any ≥24h span
|
|
24
|
+
* crosses a UTC midnight first.
|
|
25
|
+
* - On split AND at end-of-scan, the session end is stamped at the
|
|
26
|
+
* PREVIOUS/LAST event's timestamp (sortable_event_create_session_end is
|
|
27
|
+
* called with event_order_state.time_ms = u->last_event_time_ms; see
|
|
28
|
+
* also finalize_sessions) — $duration_s never includes the idle tail.
|
|
29
|
+
* - $duration_s = integer division (last_ms − first_ms) / 1000
|
|
30
|
+
* (uint32_t duration_s in sortable_event_create_session_end).
|
|
31
|
+
* - $event_count = inclusive raw event count (u->event_count++ per event;
|
|
32
|
+
* reset to 0 by sortable_event_create_session_start).
|
|
33
|
+
* - $origin_start / $origin_end = first / last event names; all four
|
|
34
|
+
* computed props are stamped on BOTH the start and end synthetic events
|
|
35
|
+
* (the `is_computed_props_set` back-fill block).
|
|
36
|
+
* - Copy props are FIRST-wins across the session (copy_to_property_set_copy
|
|
37
|
+
* skips slots already set in the filled_properties bitset; undefined
|
|
38
|
+
* values never fill) over DEFAULT_COPY_PROPERTIES
|
|
39
|
+
* (api/version_2_0/segmentation/models.py:78-97), stamped on both
|
|
40
|
+
* synthetic events.
|
|
41
|
+
* - Namespace rule: synthetic session events are EVENT_TYPE_SESSION — a
|
|
42
|
+
* separate selector namespace from regular events
|
|
43
|
+
* (libquery/event/filter.h event_type enum). Name filters and
|
|
44
|
+
* "all events" never match them, so they are returned in a SEPARATE
|
|
45
|
+
* array; callers opt in explicitly.
|
|
46
|
+
* - Sort tiebreak at equal ms: session start < regular event < session end
|
|
47
|
+
* (canonical_event_sort_comparer_lazy, libquery/event/event.c).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { resolveUserId } from './identity.js';
|
|
51
|
+
import { toMs } from '../hook-helpers/_internal.js';
|
|
52
|
+
|
|
53
|
+
const DAY_MS = 86400 * 1000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The default session copy-property list —
|
|
57
|
+
* api/version_2_0/segmentation/models.py:78-97 DEFAULT_COPY_PROPERTIES.
|
|
58
|
+
* First non-undefined value per property across the session wins.
|
|
59
|
+
*/
|
|
60
|
+
export const SESSION_COPY_PROPERTIES = [
|
|
61
|
+
'$app_build_number',
|
|
62
|
+
'$app_version_string',
|
|
63
|
+
'$browser',
|
|
64
|
+
'$city',
|
|
65
|
+
'$country_code',
|
|
66
|
+
'$current_url',
|
|
67
|
+
'$device',
|
|
68
|
+
'$manufacturer',
|
|
69
|
+
'$os',
|
|
70
|
+
'$region',
|
|
71
|
+
'mp_country_code',
|
|
72
|
+
'mp_lib',
|
|
73
|
+
'mp_platform',
|
|
74
|
+
'utm_campaign',
|
|
75
|
+
'utm_content',
|
|
76
|
+
'utm_source',
|
|
77
|
+
'$referring_domain',
|
|
78
|
+
'utm_medium',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {Object} Session
|
|
83
|
+
* @property {string} userId Resolved canonical user id.
|
|
84
|
+
* @property {number} startMs First event's timestamp (ms).
|
|
85
|
+
* @property {number} endMs LAST event's timestamp (ms) — never the idle tail.
|
|
86
|
+
* @property {number} duration_s floor((endMs − startMs) / 1000).
|
|
87
|
+
* @property {number} event_count Inclusive raw event count.
|
|
88
|
+
* @property {string} origin_start First event's name.
|
|
89
|
+
* @property {string} origin_end Last event's name.
|
|
90
|
+
* @property {Object} copyProps First-wins values over SESSION_COPY_PROPERTIES.
|
|
91
|
+
* @property {Array<Object>} events The raw event objects, time-ordered.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Split one user's time-sorted `{ e, ms }` list into sessions per the three
|
|
96
|
+
* ARB triggers. Shared by `sessionize` (full derivation) and
|
|
97
|
+
* `sessionOrdinals` (funnel session-window support).
|
|
98
|
+
*
|
|
99
|
+
* @param {string} uid
|
|
100
|
+
* @param {Array<{e: Object, ms: number}>} list - MUST be time-sorted.
|
|
101
|
+
* @param {number} timeoutMs
|
|
102
|
+
* @param {number} maxSessionMs
|
|
103
|
+
* @returns {Array<Session>}
|
|
104
|
+
*/
|
|
105
|
+
function buildUserSessions(uid, list, timeoutMs, maxSessionMs) {
|
|
106
|
+
/** @type {Array<Session>} */
|
|
107
|
+
const out = [];
|
|
108
|
+
/** @type {Session | null} */
|
|
109
|
+
let cur = null;
|
|
110
|
+
|
|
111
|
+
const open = ({ e, ms }) => {
|
|
112
|
+
cur = {
|
|
113
|
+
userId: uid,
|
|
114
|
+
startMs: ms,
|
|
115
|
+
endMs: ms,
|
|
116
|
+
duration_s: 0,
|
|
117
|
+
event_count: 0,
|
|
118
|
+
origin_start: e.event,
|
|
119
|
+
origin_end: e.event,
|
|
120
|
+
copyProps: {},
|
|
121
|
+
events: [],
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
const absorb = ({ e, ms }) => {
|
|
125
|
+
cur.endMs = ms;
|
|
126
|
+
cur.origin_end = e.event;
|
|
127
|
+
cur.event_count += 1;
|
|
128
|
+
cur.events.push(e);
|
|
129
|
+
// First-wins copy props (copy_to_property_set_copy — filled slots
|
|
130
|
+
// are skipped; undefined never fills).
|
|
131
|
+
for (const p of SESSION_COPY_PROPERTIES) {
|
|
132
|
+
if (p in cur.copyProps) continue;
|
|
133
|
+
const v = e[p];
|
|
134
|
+
if (v !== undefined && v !== null) cur.copyProps[p] = v;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const close = () => {
|
|
138
|
+
// End stamped at the LAST event's timestamp — never the idle tail.
|
|
139
|
+
cur.duration_s = Math.floor((cur.endMs - cur.startMs) / 1000);
|
|
140
|
+
out.push(cur);
|
|
141
|
+
cur = null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (const item of list) {
|
|
145
|
+
if (!cur) {
|
|
146
|
+
open(item);
|
|
147
|
+
} else {
|
|
148
|
+
const gapSplit = !!timeoutMs && (item.ms - cur.endMs > timeoutMs);
|
|
149
|
+
const maxSplit = !!maxSessionMs && (item.ms > cur.startMs + maxSessionMs);
|
|
150
|
+
const daySplit = Math.floor(item.ms / DAY_MS) !== Math.floor(cur.endMs / DAY_MS);
|
|
151
|
+
if (gapSplit || maxSplit || daySplit) {
|
|
152
|
+
close();
|
|
153
|
+
open(item);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
absorb(item);
|
|
157
|
+
}
|
|
158
|
+
if (cur) close();
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Derive sessions from raw events the way Mixpanel's session query does.
|
|
164
|
+
*
|
|
165
|
+
* @param {Array<Object>} events
|
|
166
|
+
* @param {Object} [options]
|
|
167
|
+
* @param {number} [options.timeoutMs=1800000] Gap trigger, strict `>`. Falsy disables (ARB `> 0` guard).
|
|
168
|
+
* @param {number} [options.maxSessionMs=86400000] Max-length trigger from the session's FIRST event, strict `>`. Falsy disables.
|
|
169
|
+
* @param {Map<string,string>} [options.identityMap] device_id → canonical id (see buildIdentityMap).
|
|
170
|
+
* @returns {{ sessions: Array<Session>, syntheticEvents: Array<Object> }}
|
|
171
|
+
* `syntheticEvents` is separate on purpose — synthetic session events live
|
|
172
|
+
* outside the regular event-name namespace (libquery/event/filter.h).
|
|
173
|
+
*/
|
|
174
|
+
export function sessionize(events, { timeoutMs = 30 * 60_000, maxSessionMs = 24 * 3_600_000, identityMap } = {}) {
|
|
175
|
+
if (!Array.isArray(events)) throw new Error('sessionize: events must be an array');
|
|
176
|
+
|
|
177
|
+
// Group per resolved user, dropping events with no resolvable id
|
|
178
|
+
// (empty-uid events are skipped by ARB's user-state container).
|
|
179
|
+
const byUser = new Map();
|
|
180
|
+
for (const e of events) {
|
|
181
|
+
if (!e) continue;
|
|
182
|
+
const uid = resolveUserId(e, identityMap);
|
|
183
|
+
if (!uid) continue;
|
|
184
|
+
const ms = toMs(e.time);
|
|
185
|
+
if (!Number.isFinite(ms)) continue;
|
|
186
|
+
if (!byUser.has(uid)) byUser.set(uid, []);
|
|
187
|
+
byUser.get(uid).push({ e, ms });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @type {Array<Session>} */
|
|
191
|
+
const sessions = [];
|
|
192
|
+
|
|
193
|
+
for (const [uid, list] of byUser) {
|
|
194
|
+
list.sort((a, b) => a.ms - b.ms);
|
|
195
|
+
sessions.push(...buildUserSessions(uid, list, timeoutMs, maxSessionMs));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Deterministic global order: by start time, then user id.
|
|
199
|
+
sessions.sort((a, b) => a.startMs - b.startMs || String(a.userId).localeCompare(String(b.userId)));
|
|
200
|
+
|
|
201
|
+
// Synthetic $session_start/$session_end pairs. All four computed props +
|
|
202
|
+
// copy props are stamped on BOTH events (the is_computed_props_set
|
|
203
|
+
// back-fill in sortable_event_create_session_end).
|
|
204
|
+
const syntheticEvents = [];
|
|
205
|
+
for (const s of sessions) {
|
|
206
|
+
const shared = {
|
|
207
|
+
$duration_s: s.duration_s,
|
|
208
|
+
$event_count: s.event_count,
|
|
209
|
+
$origin_start: s.origin_start,
|
|
210
|
+
$origin_end: s.origin_end,
|
|
211
|
+
...s.copyProps,
|
|
212
|
+
};
|
|
213
|
+
syntheticEvents.push({
|
|
214
|
+
event: '$session_start',
|
|
215
|
+
time: new Date(s.startMs).toISOString(),
|
|
216
|
+
user_id: s.userId,
|
|
217
|
+
...shared,
|
|
218
|
+
});
|
|
219
|
+
syntheticEvents.push({
|
|
220
|
+
event: '$session_end',
|
|
221
|
+
time: new Date(s.endMs).toISOString(),
|
|
222
|
+
user_id: s.userId,
|
|
223
|
+
...shared,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// Canonical tiebreak at equal ms: start < end
|
|
227
|
+
// (canonical_event_sort_comparer_lazy).
|
|
228
|
+
syntheticEvents.sort((a, b) =>
|
|
229
|
+
toMs(a.time) - toMs(b.time)
|
|
230
|
+
|| (a.event === b.event ? 0 : a.event === '$session_start' ? -1 : 1));
|
|
231
|
+
|
|
232
|
+
return { sessions, syntheticEvents };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Per-event session ordinals for ONE user's event stream — funnel
|
|
237
|
+
* session-count conversion-window support (P1.6.1).
|
|
238
|
+
*
|
|
239
|
+
* The funnel reader keeps a per-user session counter incremented on each
|
|
240
|
+
* $session_end event AFTER that event is processed
|
|
241
|
+
* (per_user_funnel_state_increment_session_id in funnel_query.cpp — the
|
|
242
|
+
* increment sits below funnel_query_process_event, and the equal-ms sort
|
|
243
|
+
* tiebreak puts session ends after regular events). So an event's ordinal is
|
|
244
|
+
* the count of session ENDS strictly before it, seeded 0 — equivalently the
|
|
245
|
+
* index of the session containing it — and the event that closes a session
|
|
246
|
+
* belongs to the session it closes.
|
|
247
|
+
*
|
|
248
|
+
* The whole stream is treated as a single user (funnel evaluation is
|
|
249
|
+
* per-user; events here often carry no id at all). For multi-user data,
|
|
250
|
+
* partition first, then call this per user.
|
|
251
|
+
*
|
|
252
|
+
* @param {Array<Object>} events
|
|
253
|
+
* @param {Object} [options]
|
|
254
|
+
* @param {number} [options.timeoutMs=1800000]
|
|
255
|
+
* @param {number} [options.maxSessionMs=86400000]
|
|
256
|
+
* @returns {Map<Object, number>} event object → session ordinal. Events with
|
|
257
|
+
* unparseable times are absent from the map.
|
|
258
|
+
*/
|
|
259
|
+
export function sessionOrdinals(events, { timeoutMs = 30 * 60_000, maxSessionMs = 24 * 3_600_000 } = {}) {
|
|
260
|
+
if (!Array.isArray(events)) throw new Error('sessionOrdinals: events must be an array');
|
|
261
|
+
const list = [];
|
|
262
|
+
for (const e of events) {
|
|
263
|
+
if (!e) continue;
|
|
264
|
+
const ms = toMs(e.time);
|
|
265
|
+
if (!Number.isFinite(ms)) continue;
|
|
266
|
+
list.push({ e, ms });
|
|
267
|
+
}
|
|
268
|
+
list.sort((a, b) => a.ms - b.ms);
|
|
269
|
+
const sessions = buildUserSessions('__stream__', list, timeoutMs, maxSessionMs);
|
|
270
|
+
const ordinals = new Map();
|
|
271
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
272
|
+
for (const e of sessions[i].events) ordinals.set(e, i);
|
|
273
|
+
}
|
|
274
|
+
return ordinals;
|
|
275
|
+
}
|