@ak--47/dungeon-master 1.4.3 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/HOOKS.md +1138 -0
- package/lib/core/config-validator.js +2 -2
- package/lib/orchestrators/mixpanel-sender.js +3 -2
- package/lib/utils/utils.js +38 -17
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@ak--47/dungeon-master`.
|
|
4
4
|
|
|
5
|
+
## 1.4.4 — 2026-05-06
|
|
6
|
+
|
|
7
|
+
The "GCS imports actually work now" release.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **GCS-sourced imports hung indefinitely.** `streamJSON`, `streamCSV`, and `streamParquet` used `createWriteStream({ gzip: true })` for all GCS writes, setting `Content-Encoding: gzip` on objects. The HTTP transport auto-decompressed on some environments but not others (Cloud Run). When it didn't, raw gzip bytes reached parsers, stream errors didn't propagate through `.pipe()` chains, and the pipeline promise never resolved. Fix: GCS writes no longer use `Content-Encoding: gzip`. Gzip is handled at the application level (pipe through `zlib.createGzip()`), same as local writes.
|
|
12
|
+
- **Group profiles silently skipped in batch mode.** After flush, `groupEntity.length === 0` triggered an early `continue` even when batch files existed on GCS. Events/users/ad-spend had `isBATCH_MODE` fallbacks — groups didn't.
|
|
13
|
+
- **GCS gzip finish-event timing.** Promise resolved on gzip transform's `finish` (compression done) instead of GCS stream's `finish` (upload complete), potentially producing truncated files.
|
|
14
|
+
- **Group events batch mode fallback** (latent). Added `isBATCH_MODE` guard for future use.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **GCS default format is now JSONL.** When `writeToDisk` is a `gs://` path and no `format` is specified, the default is `"json"` instead of `"csv"`. Explicit `format` settings are unaffected.
|
|
19
|
+
- **HOOKS.md shipped in npm package.** The hook encyclopedia (24 recipes, 20 principles, atom/pattern reference) is now included in the published package.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **GCS round-trip tests.** Three e2e tests that write to GCS and read back through `mixpanel-import`'s stream parser: default JSONL, gzipped JSONL, and full dungeon (events + users + groups + SCDs + ad spend). Verifies actual record counts, not just file existence.
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
- **`research/1.4.4-upgrade-guide.md`** — full details on the GCS fix, migration notes, and root cause analysis.
|
|
28
|
+
|
|
29
|
+
## 1.4.3 — 2026-05-05
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`percentUsersBornInDataset` defaults raised.** The "flat" macro preset (default) changed from 15% to 50%. All other presets raised proportionally (floor 25%). Retention/onboarding hooks now have much larger cohorts for cleaner signal.
|
|
34
|
+
- **Skill rename:** `verify-hooks` → `verify-dungeon`. Reflects broader scope (schema + hooks + identity + experiments).
|
|
35
|
+
- **Test directory cleanup.** Removed benchmark scripts, intellisense test files, and legacy test helpers. Flattened hook helper/pattern test file names.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **HOOKS.md recipe 3.22** — Retention Magic Number pattern ("N actions in first X days predicts retention"), drawn from the Twitter dungeon iteration.
|
|
40
|
+
- **Schema validation** (`lib/verify/schema-validator.js`). Catches hooks that introduce undeclared columns. Integrated into `verifyDungeon()`.
|
|
41
|
+
- **Property type helpers:** `dateRange()`, `listOf()`, `objectList()` — complete coverage of all 7 Mixpanel property data types.
|
|
42
|
+
- **Twitter/X dungeon** (`dungeons/user/twitter.js`) — consumer social platform with 4 verified hooks.
|
|
43
|
+
|
|
44
|
+
### Documentation
|
|
45
|
+
|
|
46
|
+
- **`research/1.4.3-upgrade-guide.md`** — macro preset migration, retention hook calibration lessons, schema validation API.
|
|
47
|
+
|
|
48
|
+
## 1.4.2 — 2026-05-04
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- **All 20 vertical dungeons verified STRONG or NAILED.** 200 hooks across 20 verticals evaluated and fixed via the verify-dungeon pipeline.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Various hook bugs across `dating`, `social`, `travel`, `community`, `logistics`, `media`, `fintech`, `food-delivery`, `education`, `real-estate`, `devtools`, and `marketplace` dungeons surfaced by `/verify-dungeon`.
|
|
57
|
+
- Vertical dungeon property defaults, temporal hook ordering, and cohort threshold calibration.
|
|
58
|
+
|
|
59
|
+
## 1.4.1 — 2026-05-04
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- **File path tracking (`getWrittenFiles()`).** HookedArray containers track exact file paths written during a run. Replaces fragile `ls()` + string-filter directory scans. Works for local and `gs://` paths.
|
|
64
|
+
- **`cleanup: true` config option.** Deletes all written files at end of run (local and GCS). Runs in `finally` block.
|
|
65
|
+
- **Cloud Run / serverless OOM guide** in upgrade guide — `batchSize` + `writeToDisk: 'gs://'` + `concurrency: 1` pattern for low peak memory.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **SCD multi-batch import.** Sender used `.pop()` when discovering SCD batch files — only the last batch was imported. All batch files now imported.
|
|
70
|
+
- **Warnings gated behind `verbose: true`.** Config validator and storage layer warnings no longer fire unconditionally.
|
|
71
|
+
|
|
72
|
+
### Documentation
|
|
73
|
+
|
|
74
|
+
- **`research/1.4.1-upgrade-guide.md`** — file tracking API, cleanup option, Cloud Run deployment guide.
|
|
75
|
+
|
|
5
76
|
## 1.4.0 — 2026-05-03
|
|
6
77
|
|
|
7
78
|
The "identity model + hook verification" release. Users get multi-device identity, declarative experiments, and a complete hook authoring pipeline with verification. All 20 vertical dungeons upgraded and verified.
|
package/HOOKS.md
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
# HOOKS.md -- Hook Encyclopedia
|
|
2
|
+
|
|
3
|
+
Hook reference and recipe catalog for dungeon-master. Every pattern here is
|
|
4
|
+
drawn from production dungeons. Code snippets are concrete but adaptable --
|
|
5
|
+
change event names, property names, and thresholds to fit your schema.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Quick Reference
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
hook: function (record, type, meta) { ... return record; }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Type | Fires In | `record` Is | Return | Key `meta` Fields |
|
|
16
|
+
|---|---|---|---|---|
|
|
17
|
+
| `user` | `user-loop.js:156` | User profile object | Ignored (mutate in-place) | `user`, `config`, `userIsBornInDataset` |
|
|
18
|
+
| `scd-pre` | `user-loop.js:175` | Array of SCD entries | Ignored (mutate in-place) | `profile`, `type`, `scd`, `config`, `allSCDs` |
|
|
19
|
+
| `funnel-pre` | `funnels.js:70` | Funnel config object | Ignored (mutate in-place) | `user`, `profile`, `scd`, `funnel`, `config`, `firstEventTime`, `experiment` |
|
|
20
|
+
| `event` | `events.js:176` | Single event (flat props) | **Used** (replaces event) | `user: { distinct_id }`, `config`, `datasetStart`, `datasetEnd` |
|
|
21
|
+
| `funnel-post` | `funnels.js:153` | Array of funnel events | Ignored (mutate in-place) | `user`, `profile`, `scd`, `funnel`, `config`, `experiment` |
|
|
22
|
+
| `everything` | `user-loop.js:280` | Array of ALL user events | **Used** if array returned | `profile`, `scd`, `config`, `datasetStart`, `datasetEnd`, `userIsBornInDataset`, `authTime`, `isPreAuth`, `persona` |
|
|
23
|
+
| `ad-spend` | `storage.js` | Ad spend event | Ignored | -- |
|
|
24
|
+
| `group` | `storage.js` | Group profile | Ignored | -- |
|
|
25
|
+
| `mirror` | `storage.js` | Mirror data point | Ignored | -- |
|
|
26
|
+
| `lookup` | `storage.js` | Lookup table entry | Ignored | -- |
|
|
27
|
+
|
|
28
|
+
**Per-user execution order:** `user` -> `scd-pre` -> `funnel-pre` -> `event` -> `funnel-post` -> `everything`
|
|
29
|
+
|
|
30
|
+
Storage-only hooks (`ad-spend`, `group`, `mirror`, `lookup`) fire once per
|
|
31
|
+
record when pushed to a HookedArray. Core hooks (`event`, `user`, `scd-pre`)
|
|
32
|
+
fire only in the generator/orchestrator -- storage skips them to prevent
|
|
33
|
+
double-fire mutations.
|
|
34
|
+
|
|
35
|
+
**Return rules:**
|
|
36
|
+
- `event`: return the (possibly replaced) event object. Returning a different object replaces the event entirely.
|
|
37
|
+
- `everything`: return the (possibly modified) array. Returning a filtered array removes events.
|
|
38
|
+
- All other types: mutate `record` in-place. Return value is ignored.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 2. Core Principles
|
|
43
|
+
|
|
44
|
+
1. **Schema-first.** Every property in the output must be defined in the dungeon
|
|
45
|
+
config (`events[].properties`, `userProps`, or `superProps`) with a default
|
|
46
|
+
value. Hooks modify existing values. They never invent new properties.
|
|
47
|
+
|
|
48
|
+
2. **No flag-stamping.** Never add cohort labels like `is_whale = true` or
|
|
49
|
+
`power_user = true`. Derive segments from behavioral data (event counts,
|
|
50
|
+
property values, sequences) that analysts discover via Mixpanel cohorts.
|
|
51
|
+
|
|
52
|
+
3. **Clone-don't-construct.** Injected events must be cloned from an existing
|
|
53
|
+
event using spread (`{...template, time: t, user_id: uid}`). Never build
|
|
54
|
+
events from scratch. This guarantees the schema stays consistent.
|
|
55
|
+
|
|
56
|
+
4. **Properties are FLAT.** Access `record.amount`, not
|
|
57
|
+
`record.properties.amount`. The engine flattens event properties before
|
|
58
|
+
hooks see them.
|
|
59
|
+
|
|
60
|
+
5. **Hooks are the final authority.** They override persona modifiers, world
|
|
61
|
+
events, experiment modifiers, and engagement decay. Whatever the hook writes
|
|
62
|
+
is what ships.
|
|
63
|
+
|
|
64
|
+
6. **Use `dayjs` for time, seeded `chance` for randomness.** Never use
|
|
65
|
+
`Math.random()`. Initialize `chance` at module scope with the dungeon seed:
|
|
66
|
+
`const chance = u.initChance(SEED)`.
|
|
67
|
+
|
|
68
|
+
7. **Temporal hooks go in `everything`.** Any hook that checks
|
|
69
|
+
`dayInDataset >= N` must live in the `everything` hook, not the `event`
|
|
70
|
+
hook. The `event` hook's `meta.datasetStart` produces unreliable day
|
|
71
|
+
calculations. The `everything` hook's `meta.datasetStart` is verified
|
|
72
|
+
correct (churn/silencing hooks work there consistently).
|
|
73
|
+
|
|
74
|
+
8. **Event cloning requires `everything`.** The `event` hook's return value
|
|
75
|
+
REPLACES the original event. To DUPLICATE or INJECT events (spike
|
|
76
|
+
patterns, burst clones), use the `everything` hook and `push()` to the
|
|
77
|
+
array. Only use the `event` hook return for event REPLACEMENT patterns
|
|
78
|
+
(e.g., alert triggered → incident created).
|
|
79
|
+
|
|
80
|
+
9. **Property baselines must contrast with hook targets.** If a hook sets
|
|
81
|
+
`event_type = "plan_upgraded"` during a time window, the baseline
|
|
82
|
+
distribution must make `plan_upgraded` rare (~10-15%). If it's already
|
|
83
|
+
20%+ at baseline, the hook produces no visible spike. Similarly, if a
|
|
84
|
+
hook forces `scale_direction = "down"`, the baseline must favor "up" so
|
|
85
|
+
the forced "down" creates measurable contrast.
|
|
86
|
+
|
|
87
|
+
10. **TTC effects go in `everything`, not `funnel-post`.** Funnel-post TTC
|
|
88
|
+
scaling (e.g., enterprise converts 1.4x faster) is not verifiable via
|
|
89
|
+
cross-event SQL queries — standalone events drown the within-funnel
|
|
90
|
+
signal. Move TTC-by-segment effects to the `everything` hook where you
|
|
91
|
+
can directly scale time gaps between event pairs (e.g., alert triggered
|
|
92
|
+
→ alert resolved). Use stronger factors (0.5x/1.8x) to compensate for
|
|
93
|
+
dilution by non-funnel events.
|
|
94
|
+
|
|
95
|
+
11. **Temporal mutations run AFTER all cloning.** If Hook A clones events
|
|
96
|
+
with time offsets, and Hook B mutates events in a time window, cloned
|
|
97
|
+
events can land inside B's window without getting the mutation. Fix:
|
|
98
|
+
run temporal value mutations at the END of the everything hook, after
|
|
99
|
+
all event injection/cloning is complete. This is distinct from L7/L8
|
|
100
|
+
(temporal hooks in event vs everything) — even within the everything
|
|
101
|
+
hook, ordering matters.
|
|
102
|
+
|
|
103
|
+
12. **Cohort detection must survive downstream filtering.** If Hook A
|
|
104
|
+
classifies users as "agentic" based on having certain events, and
|
|
105
|
+
Hook B later removes some of those events (churn, retention filter),
|
|
106
|
+
verification queries can't reconstruct the cohort from output data.
|
|
107
|
+
Fix: use stricter detection (3+ events instead of 1+) so surviving
|
|
108
|
+
events still identify the cohort, or accept the verification
|
|
109
|
+
limitation and verify by mechanism inspection.
|
|
110
|
+
|
|
111
|
+
13. **Deprecated feature replacement in hooks.** When a dungeon used
|
|
112
|
+
`subscription`, `attribution`, `features`, or other deprecated config
|
|
113
|
+
blocks, the 1.4 engine silently strips them. Hooks that depended on
|
|
114
|
+
properties generated by those features (e.g., `coaching_mode`,
|
|
115
|
+
`subscription_plan`) will see those properties missing. Fix: add
|
|
116
|
+
equivalent property assignments in the `user` or `everything` hook,
|
|
117
|
+
or add the property to `superProps`/`userProps` with a default value.
|
|
118
|
+
|
|
119
|
+
14. **Unseeded Chance instances break determinism.** Some pre-1.4 dungeons
|
|
120
|
+
used `new Chance()` without a seed. Replace with
|
|
121
|
+
`const chance = u.initChance(SEED)` to ensure reproducible output.
|
|
122
|
+
|
|
123
|
+
15. **TTC effects must shift timestamps, not just properties.** Mixpanel's
|
|
124
|
+
funnel TTC measures the delta between event *timestamps* (step A time →
|
|
125
|
+
step B time). Scaling a timing *property* (e.g., `wait_time_hours *= 0.67`)
|
|
126
|
+
changes what shows up in Insights AVG reports, but does NOT affect what
|
|
127
|
+
Mixpanel's funnel TTC report shows — that report uses the event's
|
|
128
|
+
timestamp, not any property value. To create a visible TTC-by-segment
|
|
129
|
+
story in Mixpanel Funnels, you MUST shift the actual event timestamps in
|
|
130
|
+
the `everything` hook using `findFirstSequence()` + `scaleFunnelTTC()`.
|
|
131
|
+
Property scaling is fine as a complementary Insights signal, but the
|
|
132
|
+
timestamp shift is the primary mechanism.
|
|
133
|
+
|
|
134
|
+
16. **Scope `funnel-pre` to specific funnels.** When a `funnel-pre` hook
|
|
135
|
+
adjusts conversion rates for a segment (e.g., free users 0.5x), apply it
|
|
136
|
+
only to the intended funnel using `meta.funnel.sequence`. Unscoped
|
|
137
|
+
funnel-pre hooks affect ALL funnels, which can create unexpected
|
|
138
|
+
interactions — higher conversion on non-target funnels consumes the
|
|
139
|
+
user's event budget, displacing standalone events and triggering churn
|
|
140
|
+
hooks in unrelated code paths.
|
|
141
|
+
```js
|
|
142
|
+
if (type === "funnel-pre") {
|
|
143
|
+
const isCertFunnel = meta.funnel?.sequence?.includes("certificate earned");
|
|
144
|
+
if (!isCertFunnel) return; // only adjust the cert funnel
|
|
145
|
+
// ... apply conversion scaling
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
17. **SCD props live in `meta.scd`, not `meta.profile`.** Slowly Changing
|
|
150
|
+
Dimension values are never present on the profile object. If your hook
|
|
151
|
+
needs the user's current SCD value (e.g., `loyalty_tier`, `plan_tier`),
|
|
152
|
+
read it from `meta.scd.<scdName>` and extract the latest entry. Reading
|
|
153
|
+
`meta.profile.<scdPropName>` will always be `undefined`.
|
|
154
|
+
|
|
155
|
+
18. **Calibrate thresholds against actual event distributions.** When a hook
|
|
156
|
+
gates behavior on "N+ events of type X in first Y days," the threshold
|
|
157
|
+
must be achievable given the event weight, total event rate, and number
|
|
158
|
+
of event types. With 200 event types and 2.5 events/user/day, a weight-7
|
|
159
|
+
event gets ~0.2 occurrences/day. A threshold of 5 in 7 days is impossible
|
|
160
|
+
for most users. Always check the distribution before setting thresholds:
|
|
161
|
+
```js
|
|
162
|
+
// Run this query to see the actual distribution
|
|
163
|
+
// SELECT count, COUNT(*) FROM (
|
|
164
|
+
// SELECT user_id, COUNT(*) as count FROM events WHERE event = 'X' GROUP BY user_id
|
|
165
|
+
// ) GROUP BY count ORDER BY count;
|
|
166
|
+
```
|
|
167
|
+
Set thresholds at roughly the 80th percentile — enough users exceed it to
|
|
168
|
+
form a meaningful cohort (~20%), but not so many that "everyone qualifies."
|
|
169
|
+
|
|
170
|
+
19. **Prefer boosts over drops for retention hooks.** Using
|
|
171
|
+
`scaleEventCount(events, "X", 1.8)` on the positive cohort produces
|
|
172
|
+
cleaner signal than `dropEventsWhere` on the negative cohort.
|
|
173
|
+
Drops compound with other drop hooks and persona-level churn — two hooks
|
|
174
|
+
each dropping 40% after day 21 combine to drop 76% for users in both
|
|
175
|
+
cohorts, masking all intended signal. Boosts are additive and don't
|
|
176
|
+
interact destructively with other hooks. Reserve drops for single-hook
|
|
177
|
+
churn patterns where the cohort is precisely defined.
|
|
178
|
+
|
|
179
|
+
20. **Compounding drop hooks destroy signal.** If Hook A drops 40% after
|
|
180
|
+
day 21 for "non-loyal" users AND Hook B drops 60% after day 21 for
|
|
181
|
+
"non-streak" users, and 95% of users are in BOTH groups, then baseline
|
|
182
|
+
post-day-21 events are pruned by ~76%. The "control group" barely exists.
|
|
183
|
+
Fix: use at most ONE drop-based retention hook per dungeon. Move other
|
|
184
|
+
retention effects to boost-based patterns (principle #19).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 3. Recipe Catalog
|
|
189
|
+
|
|
190
|
+
### Temporal Trends
|
|
191
|
+
|
|
192
|
+
#### 3.1 Conversion Change Over Time
|
|
193
|
+
|
|
194
|
+
**Hook type:** `funnel-pre` | **Meta:** `meta.firstEventTime`
|
|
195
|
+
|
|
196
|
+
**In Mixpanel:** Funnel conversion rate shows a step-change at a specific date.
|
|
197
|
+
Before the date, conversion is baseline; after, it jumps or drops.
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
// funnel-pre: feature launch boosts onboarding conversion by 20%
|
|
201
|
+
if (type === "funnel-pre") {
|
|
202
|
+
const LAUNCH = dayjs.unix(meta.datasetStart).add(60, "days").valueOf();
|
|
203
|
+
if (meta.firstEventTime > LAUNCH) {
|
|
204
|
+
record.conversionRate *= 1.2;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Real-world analogue:** Product team ships a new onboarding wizard; conversion
|
|
210
|
+
lifts overnight and stays elevated.
|
|
211
|
+
|
|
212
|
+
**Adaptation:** Change the date offset and multiplier. Use `< LAUNCH` with a
|
|
213
|
+
multiplier `< 1` for degradation stories.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
#### 3.2 Feature Launch Inflection
|
|
218
|
+
|
|
219
|
+
**Hook type:** `everything` | **Meta:** `meta.profile`, `meta.datasetStart`
|
|
220
|
+
|
|
221
|
+
**In Mixpanel:** A line chart of "Submit Feedback" broken down by "Feedback
|
|
222
|
+
Source" shows new source values ("Post Search", "Post Action") appearing only
|
|
223
|
+
after a launch date, with volume and ratings jumping.
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
// everything: contextual feedback sources appear after feature launch
|
|
227
|
+
if (type === "everything") {
|
|
228
|
+
const LAUNCH = dayjs.unix(meta.datasetStart).add(74, "days");
|
|
229
|
+
const feedbackTemplate = record.find(e => e.event === "Submit Feedback");
|
|
230
|
+
if (!feedbackTemplate) return record;
|
|
231
|
+
|
|
232
|
+
// Path A: Ask MyBuddy -> View Summary within 5 min triggers "Post Search"
|
|
233
|
+
for (const e of record) {
|
|
234
|
+
if (e.event !== "Ask MyBuddy" || !dayjs(e.time).isAfter(LAUNCH)) continue;
|
|
235
|
+
const tail = record.slice(record.indexOf(e));
|
|
236
|
+
const match = findFirstSequence(tail, ["Ask MyBuddy", "View Summary"], 5);
|
|
237
|
+
if (match && chance.bool({ likelihood: 35 })) {
|
|
238
|
+
record.push(cloneEvent(feedbackTemplate, {
|
|
239
|
+
time: dayjs(match[1].time).add(2, "minutes").toISOString(),
|
|
240
|
+
user_id: record[0].user_id,
|
|
241
|
+
"Rating": chance.integer({ min: 4, max: 5 }),
|
|
242
|
+
"Feedback Source": "Post Search",
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return record;
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Real-world analogue:** PM discovers that users at "moments of accomplishment"
|
|
251
|
+
are receptive to feedback prompts; contextual triggers outperform timed prompts.
|
|
252
|
+
|
|
253
|
+
**Adaptation:** Replace the sequence and source labels. Any behavioral trigger
|
|
254
|
+
(event count threshold, property match) can gate the injection.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
#### 3.3 End-of-Quarter Spike
|
|
259
|
+
|
|
260
|
+
**Hook type:** `event` | **Meta:** `meta.datasetStart`
|
|
261
|
+
|
|
262
|
+
**In Mixpanel:** Line chart of "billing event" filtered to `event_type =
|
|
263
|
+
"plan_upgraded"` shows a 4x spike in the final 10 days.
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
// event: days 80-90, 40% of billing events become plan upgrades
|
|
267
|
+
if (type === "event" && record.event === "billing event") {
|
|
268
|
+
const dayInDataset = dayjs(record.time).diff(dayjs.unix(meta.datasetStart), "days", true);
|
|
269
|
+
if (dayInDataset >= 80 && dayInDataset <= 90 && chance.bool({ likelihood: 40 })) {
|
|
270
|
+
record.event_type = "plan_upgraded";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Real-world analogue:** B2B SaaS revenue clusters at quarter-close as sales
|
|
276
|
+
teams pull deals forward and customers expand seats.
|
|
277
|
+
|
|
278
|
+
**Adaptation:** Change the day range and target property. Clone events for
|
|
279
|
+
volume spikes (team invites, seat additions).
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
#### 3.4 Degradation and Recovery
|
|
284
|
+
|
|
285
|
+
**Hook type:** `everything` | **Meta:** `meta.datasetStart`, `meta.datasetEnd`
|
|
286
|
+
|
|
287
|
+
**In Mixpanel:** "Agenda Error" line chart shows zero before April 10, ramps up
|
|
288
|
+
during the bug window, then decays exponentially after the fix on April 26.
|
|
289
|
+
Breakdown by "Region" shows EU dominates errors.
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
// everything: EU users get 60% error rate during bug window, exponential decay after fix
|
|
293
|
+
if (type === "everything") {
|
|
294
|
+
const BUG_START = dayjs.unix(meta.datasetEnd).subtract(20, "days");
|
|
295
|
+
const FIX_DATE = dayjs.unix(meta.datasetEnd).subtract(4, "days");
|
|
296
|
+
if (meta.profile.Region !== "EU") return record;
|
|
297
|
+
|
|
298
|
+
const errorTemplate = record.find(e => e.event === "Agenda Error") || record[0];
|
|
299
|
+
record.filter(e => e.event === "Create Agenda" && dayjs(e.time).isAfter(BUG_START))
|
|
300
|
+
.forEach(agenda => {
|
|
301
|
+
const t = dayjs(agenda.time);
|
|
302
|
+
let likelihood = 60;
|
|
303
|
+
if (t.isAfter(FIX_DATE)) {
|
|
304
|
+
// exponential decay: 60 * 0.15^(days since fix)
|
|
305
|
+
likelihood = Math.max(0, 60 * Math.pow(0.15, t.diff(FIX_DATE, "days", true)));
|
|
306
|
+
}
|
|
307
|
+
if (likelihood > 0 && chance.bool({ likelihood })) {
|
|
308
|
+
record.push(cloneEvent(errorTemplate, {
|
|
309
|
+
event: "Agenda Error",
|
|
310
|
+
time: t.add(chance.integer({ min: 2, max: 10 }), "seconds").toISOString(),
|
|
311
|
+
user_id: record[0].user_id,
|
|
312
|
+
"Error Message": "model not found in region eu-west-5-2",
|
|
313
|
+
"Error Code": 500,
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
return record;
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Real-world analogue:** A/B test deployed globally where the backend model
|
|
322
|
+
serving layer lacks coverage in certain EU regions. The experiment looks great
|
|
323
|
+
in aggregate but is silently failing for 30% of users.
|
|
324
|
+
|
|
325
|
+
**Adaptation:** Replace the region check with any profile segment. Adjust the
|
|
326
|
+
decay base (0.15 is aggressive; use 0.5 for slower recovery).
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
### Magic Numbers
|
|
331
|
+
|
|
332
|
+
#### 3.5 Inverted-U Sweet Spot
|
|
333
|
+
|
|
334
|
+
**Hook type:** `everything` | **Meta:** `meta.profile`
|
|
335
|
+
|
|
336
|
+
**In Mixpanel:** Users bucketed by count of "Onboarding Question" events show
|
|
337
|
+
peak conversion at 3 questions (~85%), dropping on both sides. Classic
|
|
338
|
+
inverted-U.
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
// everything: inverted-U conversion by onboarding question count
|
|
342
|
+
if (type === "everything") {
|
|
343
|
+
const BINS = {
|
|
344
|
+
low: [0, 3], // 0-2 questions: not enough context
|
|
345
|
+
sweet: [3, 4], // exactly 3: peak conversion
|
|
346
|
+
four: [4, 5],
|
|
347
|
+
high: [5, Infinity],
|
|
348
|
+
};
|
|
349
|
+
const DROP = { low: 75, sweet: 0, four: 20, high: 70 };
|
|
350
|
+
const bin = binUsersByEventCount(record, "Onboarding Question", BINS);
|
|
351
|
+
const dropProb = DROP[bin] ?? 0;
|
|
352
|
+
|
|
353
|
+
if (dropProb > 0 && chance.bool({ likelihood: dropProb })) {
|
|
354
|
+
// non-converter: keep only acquisition events
|
|
355
|
+
const keep = new Set(["View Shared Page", "Onboarding Question"]);
|
|
356
|
+
dropEventsWhere(record, e => !keep.has(e.event));
|
|
357
|
+
}
|
|
358
|
+
return record;
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**Real-world analogue:** Signup flow friction optimization -- too few screens
|
|
363
|
+
means users don't understand the value prop; too many means they abandon.
|
|
364
|
+
|
|
365
|
+
**Adaptation:** Change the event name, bin boundaries, and drop probabilities.
|
|
366
|
+
Add a profile-based penalty (e.g., email auth +20% drop).
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
#### 3.6 Frequency x Engagement Sweet Spot
|
|
371
|
+
|
|
372
|
+
**Hook type:** `everything` | **Meta:** none
|
|
373
|
+
|
|
374
|
+
**In Mixpanel:** Users with 3-8 "view item" events show 25% higher cart
|
|
375
|
+
amounts. Users with 9+ are window-shoppers whose checkouts drop 30%.
|
|
376
|
+
|
|
377
|
+
```js
|
|
378
|
+
// everything: view-item magic number for cart value
|
|
379
|
+
if (type === "everything") {
|
|
380
|
+
const viewCount = record.filter(e => e.event === "view item").length;
|
|
381
|
+
if (viewCount >= 3 && viewCount <= 8) {
|
|
382
|
+
// sweet spot: boost cart amounts
|
|
383
|
+
scalePropertyValue(record, e => e.event === "checkout", "amount", 1.25);
|
|
384
|
+
} else if (viewCount >= 9) {
|
|
385
|
+
// decision paralysis: drop checkouts
|
|
386
|
+
dropEventsWhere(record, e => e.event === "checkout" && chance.bool({ likelihood: 30 }));
|
|
387
|
+
}
|
|
388
|
+
return record;
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Real-world analogue:** Shoppers who browse a moderate amount convert with
|
|
393
|
+
higher carts; excessive browsing signals indecision and abandonment.
|
|
394
|
+
|
|
395
|
+
**Adaptation:** Replace event names and property. Works for any
|
|
396
|
+
count-of-A-affects-B-outcome pattern.
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
#### 3.7 CI Build Magic Number
|
|
401
|
+
|
|
402
|
+
**Hook type:** `everything` | **Meta:** none
|
|
403
|
+
|
|
404
|
+
**In Mixpanel:** Users with 15-30 builds sit in the healthy CI sweet spot
|
|
405
|
+
(30% more deploys). Users with 31+ suffer flaky-CI burnout (25% fewer deploys).
|
|
406
|
+
|
|
407
|
+
```js
|
|
408
|
+
// everything: build count magic number
|
|
409
|
+
if (type === "everything") {
|
|
410
|
+
const buildCount = record.filter(e => e.event === "build completed").length;
|
|
411
|
+
if (buildCount >= 15 && buildCount <= 30) {
|
|
412
|
+
// healthy CI: clone 30% extra deploys
|
|
413
|
+
scaleEventCount(record, "deployment completed", 1.3);
|
|
414
|
+
} else if (buildCount >= 31) {
|
|
415
|
+
// flaky burnout: drop 25% of deploys
|
|
416
|
+
scaleEventCount(record, "deployment completed", 0.75);
|
|
417
|
+
}
|
|
418
|
+
return record;
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Real-world analogue:** Healthy CI cadence drives reliable deploys; runaway
|
|
423
|
+
builds signal a flaky pipeline that scares teams off shipping.
|
|
424
|
+
|
|
425
|
+
**Adaptation:** Change the count event, target event, bin boundaries, and
|
|
426
|
+
scale factors. Use `applyFrequencyByFrequency` (Phase 4) for a declarative
|
|
427
|
+
version.
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
### Experiments
|
|
432
|
+
|
|
433
|
+
#### 3.8 A/B/C Test with Variant-Specific Effects
|
|
434
|
+
|
|
435
|
+
**Hook type:** `funnel-post` + `everything` | **Meta:** `meta.experiment`
|
|
436
|
+
|
|
437
|
+
**In Mixpanel:** Experiment report shows Variant B outperforms on downstream
|
|
438
|
+
metrics (more Add Talking Point events, higher engagement). Breakdown by
|
|
439
|
+
"Variant name" on `$experiment_started` shows even distribution.
|
|
440
|
+
|
|
441
|
+
The experiment is declared on the funnel config -- the engine handles variant
|
|
442
|
+
assignment, `$experiment_started` events, and conversion modifiers:
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
// Funnel config (declarative):
|
|
446
|
+
{
|
|
447
|
+
sequence: ["Create Agenda", "Agenda Generated"],
|
|
448
|
+
conversionRate: 60,
|
|
449
|
+
experiment: {
|
|
450
|
+
name: "Collaborative Agenda",
|
|
451
|
+
variants: [
|
|
452
|
+
{ name: "Control" },
|
|
453
|
+
{ name: "Variant A", conversionMultiplier: 1.15, ttcMultiplier: 0.9 },
|
|
454
|
+
{ name: "Variant B", conversionMultiplier: 1.35, ttcMultiplier: 0.7 },
|
|
455
|
+
],
|
|
456
|
+
startDaysBeforeEnd: 30,
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
// funnel-post: inject downstream events for Variant B
|
|
463
|
+
if (type === "funnel-post" && meta.experiment?.variantName === "Variant B") {
|
|
464
|
+
const last = record[record.length - 1];
|
|
465
|
+
if (last) {
|
|
466
|
+
const tpTemplate = record.find(e => e.event === "Add Talking Point") || last;
|
|
467
|
+
record.push(cloneEvent(tpTemplate, {
|
|
468
|
+
event: "Add Talking Point",
|
|
469
|
+
time: dayjs(last.time).add(chance.integer({ min: 5, max: 30 }), "minutes").toISOString(),
|
|
470
|
+
user_id: last.user_id,
|
|
471
|
+
"Source": "AI Suggested",
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**Real-world analogue:** A/B test where the winning variant drives measurably
|
|
478
|
+
more downstream engagement, not just higher funnel conversion.
|
|
479
|
+
|
|
480
|
+
**Adaptation:** Change the variant names, multipliers, and the downstream
|
|
481
|
+
events injected. Combine with an everything-hook EU bug story for a "looks
|
|
482
|
+
great in aggregate, broken in a segment" narrative.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
### Cohort Effects
|
|
487
|
+
|
|
488
|
+
#### 3.9 Subscription Tier Stacking
|
|
489
|
+
|
|
490
|
+
**Hook type:** `everything` | **Meta:** `meta.profile`
|
|
491
|
+
|
|
492
|
+
**In Mixpanel:** "quest turned in" avg reward_gold, broken down by
|
|
493
|
+
subscription_tier, shows Premium at 1.4x and Elite at 1.8x vs Free baseline.
|
|
494
|
+
|
|
495
|
+
```js
|
|
496
|
+
// everything: tier-based reward scaling
|
|
497
|
+
if (type === "everything") {
|
|
498
|
+
const tier = meta.profile.subscription_tier;
|
|
499
|
+
const multiplier = tier === "Elite" ? 1.8 : tier === "Premium" ? 1.4 : 1.0;
|
|
500
|
+
if (multiplier !== 1.0) {
|
|
501
|
+
scalePropertyValue(record, e => e.event === "quest turned in", "reward_gold", multiplier);
|
|
502
|
+
scalePropertyValue(record, e => e.event === "quest turned in", "reward_xp", multiplier);
|
|
503
|
+
}
|
|
504
|
+
return record;
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
**Real-world analogue:** Subscription tiers in live-service games confer
|
|
509
|
+
XP/loot bonuses that translate into measurable progress speed.
|
|
510
|
+
|
|
511
|
+
**Adaptation:** Change the profile property, event, and value property. Works
|
|
512
|
+
for any segment-scales-value pattern.
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
#### 3.10 Integration Users Succeed
|
|
517
|
+
|
|
518
|
+
**Hook type:** `everything` | **Meta:** none (derived from events)
|
|
519
|
+
|
|
520
|
+
**In Mixpanel:** Cohort of users who configured both Slack AND PagerDuty
|
|
521
|
+
integrations shows 60% lower response time and 50% faster resolution.
|
|
522
|
+
|
|
523
|
+
```js
|
|
524
|
+
// everything: integration users resolve incidents faster
|
|
525
|
+
if (type === "everything") {
|
|
526
|
+
let hasSlack = false, hasPagerduty = false;
|
|
527
|
+
record.forEach(e => {
|
|
528
|
+
if (e.event === "integration configured") {
|
|
529
|
+
if (e.integration_type === "slack") hasSlack = true;
|
|
530
|
+
if (e.integration_type === "pagerduty") hasPagerduty = true;
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
if (hasSlack && hasPagerduty) {
|
|
534
|
+
scalePropertyValue(record, e => e.event === "alert acknowledged", "response_time_mins", 0.4);
|
|
535
|
+
scalePropertyValue(record, e => e.event === "alert resolved", "resolution_time_mins", 0.5);
|
|
536
|
+
}
|
|
537
|
+
return record;
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Real-world analogue:** Teams that wire alerting into their existing comms
|
|
542
|
+
stack respond minutes faster -- the alert literally finds the human.
|
|
543
|
+
|
|
544
|
+
**Adaptation:** Replace the integration check with any compound behavioral
|
|
545
|
+
condition (two+ events, property matches, thresholds).
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
#### 3.11 Power User Behavioral Amplification
|
|
550
|
+
|
|
551
|
+
**Hook type:** `everything` | **Meta:** none (derived from events)
|
|
552
|
+
|
|
553
|
+
**In Mixpanel:** Users who used the "Ancient Compass" item earn 1.5x quest
|
|
554
|
+
rewards and get 40% more quest completions via cloned events.
|
|
555
|
+
|
|
556
|
+
```js
|
|
557
|
+
// everything: Ancient Compass users get amplified rewards + extra quests
|
|
558
|
+
if (type === "everything") {
|
|
559
|
+
const usedCompass = record.some(e => e.event === "use item" && e.item_type === "Ancient Compass");
|
|
560
|
+
if (!usedCompass) return record;
|
|
561
|
+
|
|
562
|
+
record.forEach((event, idx) => {
|
|
563
|
+
if (event.event === "quest turned in") {
|
|
564
|
+
event.reward_gold = Math.floor((event.reward_gold || 100) * 1.5);
|
|
565
|
+
event.reward_xp = Math.floor((event.reward_xp || 500) * 1.5);
|
|
566
|
+
// 40% chance: clone a bonus quest completion
|
|
567
|
+
if (chance.bool({ likelihood: 40 })) {
|
|
568
|
+
record.push(cloneEvent(event, {
|
|
569
|
+
time: dayjs(event.time).add(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
|
|
570
|
+
user_id: event.user_id,
|
|
571
|
+
quest_id: chance.pickone(questIds),
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
return record;
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
**Real-world analogue:** Players who discover a power-up item measurably
|
|
581
|
+
outperform those who don't -- classic feature discovery correlation.
|
|
582
|
+
|
|
583
|
+
**Adaptation:** Replace the trigger event/property and the amplified
|
|
584
|
+
downstream event. The pattern works for any "did X -> gets more Y" story.
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
### Operational Stories
|
|
589
|
+
|
|
590
|
+
#### 3.12 Night Deploy Failure Spike
|
|
591
|
+
|
|
592
|
+
**Hook type:** `everything` | **Meta:** none
|
|
593
|
+
|
|
594
|
+
**In Mixpanel:** "deployment completed" failure rate broken down by hour of
|
|
595
|
+
day shows 22:00-05:59 at 40% failure vs 15% baseline.
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
// everything: night deploys fail at 40% rate
|
|
599
|
+
if (type === "everything") {
|
|
600
|
+
record.forEach(e => {
|
|
601
|
+
if (e.event === "deployment completed") {
|
|
602
|
+
const hour = new Date(e.time).getUTCHours();
|
|
603
|
+
if ((hour >= 22 || hour < 6) && chance.bool({ likelihood: 40 })) {
|
|
604
|
+
e.deploy_status = "failed";
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
return record;
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
**Real-world analogue:** Night deploys fail more due to skeleton crews and
|
|
613
|
+
delayed incident response.
|
|
614
|
+
|
|
615
|
+
**Adaptation:** Change the hour range and failure likelihood. Works for any
|
|
616
|
+
time-of-day-affects-outcome pattern.
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
#### 3.13 Regional Error Injection
|
|
621
|
+
|
|
622
|
+
**Hook type:** `everything` | **Meta:** `meta.profile`
|
|
623
|
+
|
|
624
|
+
**In Mixpanel:** Error events broken down by Region show EU dominating (>90%
|
|
625
|
+
of errors), concentrated in a specific date window.
|
|
626
|
+
|
|
627
|
+
See [Recipe 3.4](#34-degradation-and-recovery) for the full implementation.
|
|
628
|
+
The key addition is a profile-segment gate:
|
|
629
|
+
|
|
630
|
+
```js
|
|
631
|
+
if (meta.profile.Region !== "EU") return record;
|
|
632
|
+
// ... inject errors only for EU users during the bug window
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
**Real-world analogue:** Region-specific infrastructure failure that only
|
|
636
|
+
affects a subset of users, invisible in aggregate metrics.
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
### Funnel Manipulation
|
|
641
|
+
|
|
642
|
+
#### 3.14 TTC by User Segment (Timestamp Shifting)
|
|
643
|
+
|
|
644
|
+
**Hook type:** `everything` | **Meta:** `meta.profile`
|
|
645
|
+
|
|
646
|
+
**In Mixpanel:** Funnel median TTC, broken down by segment, shows Enterprise
|
|
647
|
+
completing 3x faster than Free. This is the ONLY approach that affects
|
|
648
|
+
Mixpanel's Funnel TTC report — Mixpanel measures the delta between event
|
|
649
|
+
timestamps, not property values.
|
|
650
|
+
|
|
651
|
+
```js
|
|
652
|
+
// everything: shift timestamps in funnel sequences by segment
|
|
653
|
+
if (type === "everything") {
|
|
654
|
+
const factor = meta.profile?.tier === "elite" ? 0.3 : meta.profile?.tier === "free" ? 1.4 : 1.0;
|
|
655
|
+
if (factor !== 1.0) {
|
|
656
|
+
const seq = findFirstSequence(record, ["step_a", "step_b", "step_c"], 60 * 24 * 30);
|
|
657
|
+
if (seq) scaleFunnelTTC(seq, factor);
|
|
658
|
+
}
|
|
659
|
+
return record;
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
**SQL verification** (bound-sequence pattern — don't use lazy MIN→MIN):
|
|
664
|
+
```sql
|
|
665
|
+
WITH steps AS (
|
|
666
|
+
SELECT user_id, event, time::TIMESTAMP AS t
|
|
667
|
+
FROM events WHERE event IN ('step_a', 'step_b', 'step_c')
|
|
668
|
+
),
|
|
669
|
+
funnel AS (
|
|
670
|
+
SELECT DISTINCT ON (a.user_id) a.user_id, a.t AS start_t,
|
|
671
|
+
(SELECT MIN(t) FROM steps c
|
|
672
|
+
WHERE c.user_id = a.user_id AND c.event = 'step_c' AND c.t > a.t) AS end_t
|
|
673
|
+
FROM steps a WHERE a.event = 'step_a'
|
|
674
|
+
ORDER BY a.user_id, a.t
|
|
675
|
+
)
|
|
676
|
+
SELECT segment,
|
|
677
|
+
COUNT(*) AS users,
|
|
678
|
+
ROUND(MEDIAN(EXTRACT(EPOCH FROM (end_t - start_t)) / 60), 1) AS median_min
|
|
679
|
+
FROM funnel JOIN users USING (user_id)
|
|
680
|
+
WHERE end_t IS NOT NULL GROUP BY segment ORDER BY median_min;
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
**Warning:** Never use the lazy proxy `MIN(step_a.time) → MIN(step_c.time)` per
|
|
684
|
+
user. This mixes events from different funnel passes and produces inverted or
|
|
685
|
+
flat results. Always bind the sequence: first A, then first C *after that A*.
|
|
686
|
+
|
|
687
|
+
**Real-world analogue:** Enterprise customers with dedicated CSMs and priority
|
|
688
|
+
support complete multi-step workflows faster.
|
|
689
|
+
|
|
690
|
+
**Adaptation:** Change the profile key, funnel steps, and factors. Use stronger
|
|
691
|
+
factors (0.3x/1.4x) to produce clear separation in the funnel TTC report.
|
|
692
|
+
|
|
693
|
+
#### 3.14b Supplementary Property Scaling
|
|
694
|
+
|
|
695
|
+
Optionally also scale timing *properties* (e.g., `response_time_mins`) by the
|
|
696
|
+
same segment. This creates a complementary signal visible in Mixpanel Insights
|
|
697
|
+
(`AVG(property) GROUP BY segment`) but does NOT affect the Funnel TTC report.
|
|
698
|
+
Useful when the dungeon has timing properties on the relevant events:
|
|
699
|
+
|
|
700
|
+
```js
|
|
701
|
+
// everything: ALSO scale timing properties for Insights signal
|
|
702
|
+
scalePropertyValue(record, e => e.event === "alert acknowledged", "response_time_mins", factor);
|
|
703
|
+
scalePropertyValue(record, e => e.event === "alert resolved", "resolution_time_mins", factor);
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
This is supplementary. The timestamp shift (3.14) is the primary mechanism.
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
#### 3.15 Funnel Conversion by Profile
|
|
711
|
+
|
|
712
|
+
**Hook type:** `funnel-pre` | **Meta:** `meta.profile`, `meta.funnel`
|
|
713
|
+
|
|
714
|
+
**In Mixpanel:** Funnel conversion rate broken down by a user property shows
|
|
715
|
+
paid users converting at 1.3x the rate of free users.
|
|
716
|
+
|
|
717
|
+
```js
|
|
718
|
+
// funnel-pre: paid users get boosted conversion — SCOPED to a specific funnel
|
|
719
|
+
if (type === "funnel-pre") {
|
|
720
|
+
// Always scope to the intended funnel (see principle #16)
|
|
721
|
+
const isTargetFunnel = meta.funnel?.sequence?.includes("certificate earned");
|
|
722
|
+
if (!isTargetFunnel) return;
|
|
723
|
+
|
|
724
|
+
const tier = meta.profile?.plan_tier;
|
|
725
|
+
if (tier === "enterprise" || tier === "business") {
|
|
726
|
+
record.conversionRate = Math.min(95, record.conversionRate * 1.3);
|
|
727
|
+
} else if (tier === "free") {
|
|
728
|
+
record.conversionRate *= 0.7;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
**Real-world analogue:** Paid-tier users who've invested in the product
|
|
734
|
+
complete multi-step workflows at higher rates.
|
|
735
|
+
|
|
736
|
+
**Adaptation:** Change the profile key, multipliers, and funnel scope check.
|
|
737
|
+
Always include the `isTargetFunnel` guard — unscoped funnel-pre hooks affect
|
|
738
|
+
ALL funnels and create cascading event-budget interactions (see principle #16).
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
742
|
+
### Event Injection
|
|
743
|
+
|
|
744
|
+
#### 3.16 Binge-Watching Pattern
|
|
745
|
+
|
|
746
|
+
**Hook type:** `everything` | **Meta:** none (derived from events)
|
|
747
|
+
|
|
748
|
+
**In Mixpanel:** Users with 3+ consecutive completions show 1.5x more
|
|
749
|
+
completions per user. Pause events are suppressed for bingers.
|
|
750
|
+
|
|
751
|
+
```js
|
|
752
|
+
// everything: binge-watchers get extra playback pairs, fewer pauses
|
|
753
|
+
if (type === "everything") {
|
|
754
|
+
// detect 3+ consecutive completions
|
|
755
|
+
let streak = 0, maxStreak = 0;
|
|
756
|
+
record.forEach(e => {
|
|
757
|
+
if (e.event === "playback completed") { streak++; maxStreak = Math.max(maxStreak, streak); }
|
|
758
|
+
else if (e.event !== "playback started") { streak = 0; }
|
|
759
|
+
});
|
|
760
|
+
if (maxStreak < 3) return record;
|
|
761
|
+
|
|
762
|
+
// suppress 60% of pauses
|
|
763
|
+
dropEventsWhere(record, e => e.event === "playback paused" && chance.bool({ likelihood: 60 }));
|
|
764
|
+
|
|
765
|
+
// clone start+complete pairs for 40% of completions
|
|
766
|
+
const startTemplate = record.find(e => e.event === "playback started");
|
|
767
|
+
record.filter(e => e.event === "playback completed").forEach(e => {
|
|
768
|
+
if (!chance.bool({ likelihood: 40 })) return;
|
|
769
|
+
const t = dayjs(e.time);
|
|
770
|
+
if (startTemplate) {
|
|
771
|
+
record.push(cloneEvent(startTemplate, {
|
|
772
|
+
time: t.add(chance.integer({ min: 1, max: 5 }), "minutes").toISOString(),
|
|
773
|
+
user_id: e.user_id,
|
|
774
|
+
content_type: "series",
|
|
775
|
+
}));
|
|
776
|
+
}
|
|
777
|
+
record.push(cloneEvent(e, {
|
|
778
|
+
time: t.add(chance.integer({ min: 30, max: 90 }), "minutes").toISOString(),
|
|
779
|
+
user_id: e.user_id,
|
|
780
|
+
}));
|
|
781
|
+
});
|
|
782
|
+
return record;
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
**Real-world analogue:** Autoplay and cliffhangers push hooked viewers through
|
|
787
|
+
entire seasons in a sitting.
|
|
788
|
+
|
|
789
|
+
**Adaptation:** Replace event names. The pattern (detect streak -> suppress
|
|
790
|
+
interrupts -> clone continuation pairs) generalizes to any repeat-consumption
|
|
791
|
+
flow.
|
|
792
|
+
|
|
793
|
+
---
|
|
794
|
+
|
|
795
|
+
#### 3.17 Contextual Event Injection
|
|
796
|
+
|
|
797
|
+
**Hook type:** `everything` | **Meta:** `meta.datasetStart`
|
|
798
|
+
|
|
799
|
+
**In Mixpanel:** Flows report shows "Ask MyBuddy" -> "View Summary" as a
|
|
800
|
+
strong preceding path for "Submit Feedback". Feedback source breakdown reveals
|
|
801
|
+
"Post Search" only appearing after the feature launch date.
|
|
802
|
+
|
|
803
|
+
```js
|
|
804
|
+
// everything: detect Ask -> View within 5 min, inject contextual feedback
|
|
805
|
+
if (type === "everything") {
|
|
806
|
+
const LAUNCH = dayjs.unix(meta.datasetStart).add(74, "days");
|
|
807
|
+
const feedbackTemplate = record.find(e => e.event === "Submit Feedback");
|
|
808
|
+
if (!feedbackTemplate) return record;
|
|
809
|
+
|
|
810
|
+
for (let i = 0; i < record.length; i++) {
|
|
811
|
+
if (record[i].event !== "Ask MyBuddy") continue;
|
|
812
|
+
if (!dayjs(record[i].time).isAfter(LAUNCH)) continue;
|
|
813
|
+
const tail = record.slice(i);
|
|
814
|
+
const match = findFirstSequence(tail, ["Ask MyBuddy", "View Summary"], 5);
|
|
815
|
+
if (match && chance.bool({ likelihood: 35 })) {
|
|
816
|
+
record.push(cloneEvent(feedbackTemplate, {
|
|
817
|
+
time: dayjs(match[1].time).add(2, "minutes").toISOString(),
|
|
818
|
+
user_id: record[0].user_id,
|
|
819
|
+
"Rating": chance.integer({ min: 4, max: 5 }),
|
|
820
|
+
"Feedback Source": "Post Search",
|
|
821
|
+
}));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return record;
|
|
825
|
+
}
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
**Real-world analogue:** Smart feedback prompts triggered at moments of
|
|
829
|
+
accomplishment dramatically outperform random timed prompts.
|
|
830
|
+
|
|
831
|
+
**Adaptation:** Replace the trigger sequence and injected event. The
|
|
832
|
+
`findFirstSequence` atom handles the gap detection; change the max gap (in
|
|
833
|
+
minutes) to match your use case.
|
|
834
|
+
|
|
835
|
+
---
|
|
836
|
+
|
|
837
|
+
### Cross-Event State
|
|
838
|
+
|
|
839
|
+
#### 3.18 Closure-Based State (Cost Overrun -> Scale Down)
|
|
840
|
+
|
|
841
|
+
**Hook type:** `event` | **Meta:** none (module-level Map)
|
|
842
|
+
|
|
843
|
+
**In Mixpanel:** Sequencing users' cost reports with 25%+ cost_change_percent
|
|
844
|
+
followed by their next "infrastructure scaled" event shows 100% of those
|
|
845
|
+
next-scale events are `scale_direction = "down"`.
|
|
846
|
+
|
|
847
|
+
```js
|
|
848
|
+
// Module-level Map — persists across hook calls within a single dungeon run
|
|
849
|
+
const costOverrunUsers = new Map();
|
|
850
|
+
|
|
851
|
+
// event: cost report > 25% records user; next scale event forced down
|
|
852
|
+
if (type === "event") {
|
|
853
|
+
if (record.event === "cost report generated" && record.cost_change_percent > 25) {
|
|
854
|
+
costOverrunUsers.set(record.user_id, true);
|
|
855
|
+
}
|
|
856
|
+
if (record.event === "infrastructure scaled" && costOverrunUsers.has(record.user_id)) {
|
|
857
|
+
record.scale_direction = "down";
|
|
858
|
+
costOverrunUsers.delete(record.user_id);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
**Real-world analogue:** A surprise cloud bill triggers an immediate
|
|
864
|
+
downscale; no engineer ignores a 25% month-over-month cost jump.
|
|
865
|
+
|
|
866
|
+
**Adaptation:** Replace the trigger condition and the forced property value.
|
|
867
|
+
Module-level Maps work for any "event A for user X affects their next event B"
|
|
868
|
+
pattern. The Map acts as a one-shot flag that is consumed on the next match.
|
|
869
|
+
|
|
870
|
+
---
|
|
871
|
+
|
|
872
|
+
#### 3.19 Failed Deploy Recovery
|
|
873
|
+
|
|
874
|
+
**Hook type:** `event` | **Meta:** none (module-level Map)
|
|
875
|
+
|
|
876
|
+
**In Mixpanel:** Successful deploys immediately following a failed deploy show
|
|
877
|
+
1.5x longer duration, reflecting the extra verification overhead.
|
|
878
|
+
|
|
879
|
+
```js
|
|
880
|
+
const failedDeployUsers = new Map();
|
|
881
|
+
|
|
882
|
+
if (type === "event" && record.event === "deployment pipeline run") {
|
|
883
|
+
if (record.status === "failed") {
|
|
884
|
+
failedDeployUsers.set(record.user_id, true);
|
|
885
|
+
} else if (record.status === "success" && failedDeployUsers.has(record.user_id)) {
|
|
886
|
+
record.duration_sec = Math.floor((record.duration_sec || 300) * 1.5);
|
|
887
|
+
failedDeployUsers.delete(record.user_id);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
**Real-world analogue:** After a bad deploy, teams add manual gates and extra
|
|
893
|
+
verification steps that slow the very next release.
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
### Profile Enrichment
|
|
898
|
+
|
|
899
|
+
#### 3.20 Segment-Based Profile Enrichment
|
|
900
|
+
|
|
901
|
+
**Hook type:** `user` | **Meta:** none
|
|
902
|
+
|
|
903
|
+
**In Mixpanel:** Average user property "seat_count" broken down by
|
|
904
|
+
"company_size" shows a monotonic ramp from startup (1-5) to enterprise
|
|
905
|
+
(50-500).
|
|
906
|
+
|
|
907
|
+
```js
|
|
908
|
+
// user: company size determines seat count, ACV, and CSM assignment
|
|
909
|
+
if (type === "user") {
|
|
910
|
+
const size = record.company_size;
|
|
911
|
+
if (size === "enterprise") {
|
|
912
|
+
record.seat_count = chance.integer({ min: 50, max: 500 });
|
|
913
|
+
record.annual_contract_value = chance.integer({ min: 50000, max: 500000 });
|
|
914
|
+
record.customer_success_manager = true;
|
|
915
|
+
} else if (size === "startup") {
|
|
916
|
+
record.seat_count = chance.integer({ min: 1, max: 5 });
|
|
917
|
+
record.annual_contract_value = chance.integer({ min: 0, max: 3600 });
|
|
918
|
+
record.customer_success_manager = false;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
**Real-world analogue:** B2B SaaS pricing scales orders of magnitude across
|
|
924
|
+
customer segments.
|
|
925
|
+
|
|
926
|
+
**Adaptation:** Change the profile properties and segment values. Use for any
|
|
927
|
+
"profile property A determines profile properties B, C, D" pattern.
|
|
928
|
+
|
|
929
|
+
---
|
|
930
|
+
|
|
931
|
+
### Churn and Retention
|
|
932
|
+
|
|
933
|
+
#### 3.21 Hash-Based Churn Silencing
|
|
934
|
+
|
|
935
|
+
**Hook type:** `everything` | **Meta:** `meta.datasetStart`
|
|
936
|
+
|
|
937
|
+
**In Mixpanel:** Retention report shows a visible cliff at day 30, with 10-20%
|
|
938
|
+
of users going completely silent.
|
|
939
|
+
|
|
940
|
+
```js
|
|
941
|
+
// everything: deterministic 20% of users go silent after day 30
|
|
942
|
+
if (type === "everything") {
|
|
943
|
+
const uid = record[0]?.user_id || record[0]?.device_id || "";
|
|
944
|
+
const idHash = String(uid).split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
945
|
+
if (idHash % 5 !== 0) return record; // only 20% of users
|
|
946
|
+
|
|
947
|
+
const cutoff = dayjs.unix(meta.datasetStart).add(30, "days");
|
|
948
|
+
dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
|
|
949
|
+
return record;
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
**Real-world analogue:** Most SaaS churn happens silently -- accounts simply
|
|
954
|
+
stop logging in long before the formal cancellation.
|
|
955
|
+
|
|
956
|
+
**Adaptation:** Change the hash modulus (5 = 20%, 10 = 10%) and the day
|
|
957
|
+
cutoff. Use char-code hashing for deterministic, seedless cohort assignment
|
|
958
|
+
that survives re-runs.
|
|
959
|
+
|
|
960
|
+
---
|
|
961
|
+
|
|
962
|
+
#### 3.22 Retention Magic Number (N Actions in First X Days)
|
|
963
|
+
|
|
964
|
+
**Hook type:** `everything` | **Meta:** `meta.userIsBornInDataset`
|
|
965
|
+
|
|
966
|
+
**In Mixpanel:** Retention report — users who performed 5+ "user followed"
|
|
967
|
+
events in their first 14 days retain ~2x better past day 36 than users who
|
|
968
|
+
didn't hit that threshold. Discoverable via behavioral cohort comparison.
|
|
969
|
+
|
|
970
|
+
```js
|
|
971
|
+
// everything: born-in-dataset users with 5+ follows in first 14 days are retained
|
|
972
|
+
if (type === "everything") {
|
|
973
|
+
if (!meta.userIsBornInDataset) return record;
|
|
974
|
+
const firstEventTime = record[0]?.time;
|
|
975
|
+
if (!firstEventTime) return record;
|
|
976
|
+
|
|
977
|
+
const userStart = dayjs(firstEventTime);
|
|
978
|
+
const windowEnd = userStart.add(14, "days").toISOString();
|
|
979
|
+
const followBin = binUsersByEventInRange(
|
|
980
|
+
record, "user followed",
|
|
981
|
+
firstEventTime, windowEnd,
|
|
982
|
+
{ retained: [5, Infinity], not_retained: [0, 5] }
|
|
983
|
+
);
|
|
984
|
+
if (followBin === "not_retained") {
|
|
985
|
+
// Silence 36 days after user's first event
|
|
986
|
+
const cutoff = userStart.add(36, "days");
|
|
987
|
+
dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
|
|
988
|
+
}
|
|
989
|
+
return record;
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
**Real-world analogue:** Twitter/social networks have a well-documented "aha
|
|
994
|
+
moment" — users who follow N accounts in their first week build a feed worth
|
|
995
|
+
returning to. Below that threshold, the timeline is empty and users churn.
|
|
996
|
+
|
|
997
|
+
**Key design decisions:**
|
|
998
|
+
|
|
999
|
+
- **User-relative cutoff, not dataset-relative.** The silence cutoff is anchored
|
|
1000
|
+
to each user's first event (`userStart.add(36, "days")`), not `datasetStart`.
|
|
1001
|
+
A dataset-anchored cutoff would miss late-born users entirely — their first
|
|
1002
|
+
event is already past the cutoff date.
|
|
1003
|
+
|
|
1004
|
+
- **`binUsersByEventInRange` over manual counting.** The atom handles time
|
|
1005
|
+
parsing and bin matching. Use it instead of rolling your own
|
|
1006
|
+
`filter().length >= N` to avoid ISO-string / unix-seconds footguns.
|
|
1007
|
+
|
|
1008
|
+
- **`percentUsersBornInDataset: 50` is important.** At the default 15%, only
|
|
1009
|
+
~750 of 5K users are born-in-dataset. After splitting into retained/not-retained,
|
|
1010
|
+
the retained cohort can be <100 users — too small for reliable signal. Bump to
|
|
1011
|
+
50% for retention hooks. The "flat" macro preset defaults to 50%.
|
|
1012
|
+
|
|
1013
|
+
- **Threshold calibration.** The threshold (5 follows) × window (14 days) must
|
|
1014
|
+
be achievable but not trivial given the event rate. At 5 events/user/day with
|
|
1015
|
+
`user followed` at weight 5 out of ~84 total weight, expect ~0.3 follows/day
|
|
1016
|
+
→ ~4.2 follows in 14 days. Threshold of 5 means ~15-20% of born users qualify.
|
|
1017
|
+
Too high a threshold (7+ in 10 days) produces cohorts < 5% — too small.
|
|
1018
|
+
|
|
1019
|
+
**Adaptation:** Replace the event name, threshold, window, and cutoff. Works
|
|
1020
|
+
for any "early activation predicts retention" story: messages sent, items
|
|
1021
|
+
purchased, friends added, content created. The pattern generalizes to any
|
|
1022
|
+
product's "aha moment" hypothesis.
|
|
1023
|
+
|
|
1024
|
+
---
|
|
1025
|
+
|
|
1026
|
+
#### 3.23 Deprecated Feature Replacement
|
|
1027
|
+
|
|
1028
|
+
**Hook type:** `user` + `everything` | **Meta:** `meta.profile`
|
|
1029
|
+
|
|
1030
|
+
**In Mixpanel:** User property `subscription_tier` drives downstream event
|
|
1031
|
+
patterns (premium users get higher conversion, more features). Works
|
|
1032
|
+
identically to the deprecated `subscription` config block.
|
|
1033
|
+
|
|
1034
|
+
```js
|
|
1035
|
+
// user: assign subscription tier based on persona/hash
|
|
1036
|
+
if (type === "user") {
|
|
1037
|
+
const hash = String(record.distinct_id || "").charCodeAt(0) % 10;
|
|
1038
|
+
record.subscription_tier = hash < 6 ? "free" : hash < 8 ? "monthly" : "annual";
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// everything: use tier to drive effects
|
|
1042
|
+
if (type === "everything") {
|
|
1043
|
+
const tier = meta.profile.subscription_tier;
|
|
1044
|
+
if (tier === "annual") {
|
|
1045
|
+
record.forEach(e => {
|
|
1046
|
+
if (e.event === "feature used") e.feature_limit = 999;
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
**Real-world analogue:** Subscription tiers gate features and drive
|
|
1053
|
+
engagement — a pattern previously handled by the deprecated `subscription`
|
|
1054
|
+
config block, now reproduced via hooks.
|
|
1055
|
+
|
|
1056
|
+
**Adaptation:** Replace `subscription_tier` with any deprecated feature's
|
|
1057
|
+
key property. Use the `user` hook for assignment (runs once) and
|
|
1058
|
+
`everything` for downstream effects. Add the property to `userProps` and
|
|
1059
|
+
`superProps` with matching default values.
|
|
1060
|
+
|
|
1061
|
+
---
|
|
1062
|
+
|
|
1063
|
+
#### 3.24 Post-Clone Temporal Mutation
|
|
1064
|
+
|
|
1065
|
+
**Hook type:** `everything` (must run LAST) | **Meta:** `meta.datasetStart`
|
|
1066
|
+
|
|
1067
|
+
**In Mixpanel:** A time-window effect (price spike, error surge) applies
|
|
1068
|
+
consistently to ALL events in the window, including events cloned by
|
|
1069
|
+
earlier hooks that happen to land in the window.
|
|
1070
|
+
|
|
1071
|
+
```js
|
|
1072
|
+
// WRONG: temporal mutation runs BEFORE cloning — clones miss the effect
|
|
1073
|
+
userEvents.forEach(e => { /* temporal mutation */ });
|
|
1074
|
+
// ... later hooks clone events into the same window
|
|
1075
|
+
|
|
1076
|
+
// RIGHT: temporal mutation runs AFTER all cloning
|
|
1077
|
+
// [all cloning hooks run first]
|
|
1078
|
+
// Then at the end:
|
|
1079
|
+
userEvents.forEach(e => {
|
|
1080
|
+
if (e.event !== "offer submitted") return;
|
|
1081
|
+
const t = dayjs(e.time);
|
|
1082
|
+
if (t.isAfter(springStart) && t.isBefore(springEnd)) {
|
|
1083
|
+
e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
**Real-world analogue:** Seasonal price effects apply to ALL transactions
|
|
1089
|
+
in the window, regardless of how they were generated.
|
|
1090
|
+
|
|
1091
|
+
**Adaptation:** Any time-window value mutation that co-exists with event
|
|
1092
|
+
cloning hooks. Move the temporal mutation to the end of the everything
|
|
1093
|
+
hook, after all push/splice operations.
|
|
1094
|
+
|
|
1095
|
+
---
|
|
1096
|
+
|
|
1097
|
+
## 4. Phase 3 Atom Reference
|
|
1098
|
+
|
|
1099
|
+
Import from `@ak--47/dungeon-master/hook-helpers`:
|
|
1100
|
+
|
|
1101
|
+
| Atom | Module | Signature | Purpose |
|
|
1102
|
+
|---|---|---|---|
|
|
1103
|
+
| `binUsersByEventCount` | cohort | `(events, eventName, bins) -> string\|null` | Classify user into a named bin by event count |
|
|
1104
|
+
| `binUsersByEventInRange` | cohort | `(events, eventName, start, end, bins) -> string\|null` | Same, but only counts events in a time range |
|
|
1105
|
+
| `countEventsBetween` | cohort | `(events, eventA, eventB) -> number` | Count events between first A and first B |
|
|
1106
|
+
| `userInProfileSegment` | cohort | `(profile, key, values) -> boolean` | Check if profile property matches segment |
|
|
1107
|
+
| `cloneEvent` | mutate | `(template, overrides?) -> event` | Shallow clone with overrides |
|
|
1108
|
+
| `dropEventsWhere` | mutate | `(events, predicate) -> number` | Remove matching events in-place |
|
|
1109
|
+
| `scaleEventCount` | mutate | `(events, eventName, factor) -> number` | Scale count of an event type (clone or drop) |
|
|
1110
|
+
| `scalePropertyValue` | mutate | `(events, predicate, prop, factor) -> number` | Multiply a numeric property on matching events |
|
|
1111
|
+
| `shiftEventTime` | mutate | `(event, deltaMs) -> event` | Shift one event's timestamp |
|
|
1112
|
+
| `scaleTimingBetween` | timing | `(events, eventA, eventB, factor) -> boolean` | Scale the gap between first A and first B |
|
|
1113
|
+
| `scaleFunnelTTC` | timing | `(funnelEvents, factor) -> number` | Scale all offsets from the funnel's first event |
|
|
1114
|
+
| `findFirstSequence` | timing | `(events, names[], maxGapMin) -> events[]\|null` | Detect ordered sequence within a max gap |
|
|
1115
|
+
| `injectAfterEvent` | inject | `(events, source, template, gapMs, overrides?) -> event` | Splice a clone after a specific event |
|
|
1116
|
+
| `injectBetween` | inject | `(events, eventA, eventB, template, overrides?) -> event` | Splice a clone at the midpoint of A-B gap |
|
|
1117
|
+
| `injectBurst` | inject | `(events, template, count, anchor, spreadMs, overrides?) -> events[]` | Inject N clones distributed around an anchor time |
|
|
1118
|
+
| `isPreAuthEvent` | identity | `(event, authTime) -> boolean` | Check if event is before the user's stitch |
|
|
1119
|
+
| `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition events by auth boundary |
|
|
1120
|
+
|
|
1121
|
+
Full JSDoc in `lib/hook-helpers/*.js`.
|
|
1122
|
+
|
|
1123
|
+
---
|
|
1124
|
+
|
|
1125
|
+
## 5. Phase 4 Pattern Reference
|
|
1126
|
+
|
|
1127
|
+
Import from `@ak--47/dungeon-master/hook-patterns`:
|
|
1128
|
+
|
|
1129
|
+
| Pattern | Hook Type | Signature | Mixpanel Report |
|
|
1130
|
+
|---|---|---|---|
|
|
1131
|
+
| `applyFrequencyByFrequency` | everything | `(events, profile, { cohortEvent, bins, targetEvent, multipliers })` | Frequency of A by per-user count of B |
|
|
1132
|
+
| `applyFunnelFrequencyBreakdown` | funnel-post | `(allEvents, profile, funnelEvents, { cohortEvent, bins, dropMultipliers })` | Funnel conversion by per-user activity bucket |
|
|
1133
|
+
| `applyAggregateByBin` | everything | `(events, profile, { cohortEvent, bins, event, propertyName, deltas })` | Avg property value by per-user activity bucket |
|
|
1134
|
+
| `applyTTCBySegment` | funnel-post | `(funnelEvents, profile, { segmentKey, factors })` | Funnel median TTC by profile segment |
|
|
1135
|
+
| `applyAttributedBySource` | everything | `(events, profile, { sourceEvent, sourceProperty, downstreamEvent, weights })` | Conversions by source (first/last touch) |
|
|
1136
|
+
|
|
1137
|
+
Full JSDoc in `lib/hook-patterns/*.js`. Pair with `emulateBreakdown` from
|
|
1138
|
+
`@ak--47/dungeon-master/verify` to assert patterns in CI.
|
|
@@ -283,7 +283,7 @@ export function validateDungeonConfig(config) {
|
|
|
283
283
|
hasAnonIds = false,
|
|
284
284
|
hasSessionIds = false,
|
|
285
285
|
sessionTimeout = 30,
|
|
286
|
-
format
|
|
286
|
+
format,
|
|
287
287
|
token = null,
|
|
288
288
|
region = "US",
|
|
289
289
|
writeToDisk = false,
|
|
@@ -592,7 +592,7 @@ export function validateDungeonConfig(config) {
|
|
|
592
592
|
avgDevicePerUser,
|
|
593
593
|
hasSessionIds,
|
|
594
594
|
sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
|
|
595
|
-
format,
|
|
595
|
+
format: format || (typeof writeToDisk === 'string' && writeToDisk.startsWith('gs://') ? 'json' : 'csv'),
|
|
596
596
|
token,
|
|
597
597
|
region,
|
|
598
598
|
writeToDisk,
|
|
@@ -121,7 +121,8 @@ export async function sendToMixpanel(context) {
|
|
|
121
121
|
// Import group profiles
|
|
122
122
|
if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
|
|
123
123
|
for (const groupEntity of groupProfilesData) {
|
|
124
|
-
if (!groupEntity
|
|
124
|
+
if (!groupEntity) continue;
|
|
125
|
+
if (groupEntity.length === 0 && !isBATCH_MODE) continue;
|
|
125
126
|
const groupKey = groupEntity?.groupKey;
|
|
126
127
|
log(` Group Profiles (${groupKey})`);
|
|
127
128
|
let groupProfilesToImport = u.deepClone(groupEntity);
|
|
@@ -141,7 +142,7 @@ export async function sendToMixpanel(context) {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
// Import group events
|
|
144
|
-
if (groupEventData?.length > 0) {
|
|
145
|
+
if (groupEventData?.length > 0 || (isBATCH_MODE && groupEventData)) {
|
|
145
146
|
log(` Group Events`);
|
|
146
147
|
let groupEventDataToImport = u.deepClone(groupEventData);
|
|
147
148
|
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEventData.length === 0);
|
package/lib/utils/utils.js
CHANGED
|
@@ -579,7 +579,16 @@ function streamJSON(filePath, data, options = {}) {
|
|
|
579
579
|
|
|
580
580
|
if (filePath?.startsWith('gs://')) {
|
|
581
581
|
const { uri, bucket, file } = parseGCSUri(filePath);
|
|
582
|
-
|
|
582
|
+
const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
|
|
583
|
+
gcsStream.on('finish', () => resolve(filePath));
|
|
584
|
+
gcsStream.on('error', reject);
|
|
585
|
+
if (gzip) {
|
|
586
|
+
const gzipStream = zlib.createGzip();
|
|
587
|
+
gzipStream.pipe(gcsStream);
|
|
588
|
+
writeStream = gzipStream;
|
|
589
|
+
} else {
|
|
590
|
+
writeStream = gcsStream;
|
|
591
|
+
}
|
|
583
592
|
}
|
|
584
593
|
else {
|
|
585
594
|
writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
|
@@ -593,9 +602,9 @@ function streamJSON(filePath, data, options = {}) {
|
|
|
593
602
|
writeStream.write(JSON.stringify(item) + '\n');
|
|
594
603
|
});
|
|
595
604
|
writeStream.end();
|
|
596
|
-
|
|
597
|
-
resolve(filePath);
|
|
598
|
-
}
|
|
605
|
+
if (!filePath?.startsWith('gs://')) {
|
|
606
|
+
writeStream.on('finish', () => resolve(filePath));
|
|
607
|
+
}
|
|
599
608
|
writeStream.on('error', reject);
|
|
600
609
|
});
|
|
601
610
|
}
|
|
@@ -607,7 +616,16 @@ function streamCSV(filePath, data, options = {}) {
|
|
|
607
616
|
|
|
608
617
|
if (filePath?.startsWith('gs://')) {
|
|
609
618
|
const { uri, bucket, file } = parseGCSUri(filePath);
|
|
610
|
-
|
|
619
|
+
const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
|
|
620
|
+
gcsStream.on('finish', () => resolve(filePath));
|
|
621
|
+
gcsStream.on('error', reject);
|
|
622
|
+
if (gzip) {
|
|
623
|
+
const gzipStream = zlib.createGzip();
|
|
624
|
+
gzipStream.pipe(gcsStream);
|
|
625
|
+
writeStream = gzipStream;
|
|
626
|
+
} else {
|
|
627
|
+
writeStream = gcsStream;
|
|
628
|
+
}
|
|
611
629
|
}
|
|
612
630
|
else {
|
|
613
631
|
writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
|
@@ -635,9 +653,9 @@ function streamCSV(filePath, data, options = {}) {
|
|
|
635
653
|
});
|
|
636
654
|
|
|
637
655
|
writeStream.end();
|
|
638
|
-
|
|
639
|
-
resolve(filePath);
|
|
640
|
-
}
|
|
656
|
+
if (!filePath?.startsWith('gs://')) {
|
|
657
|
+
writeStream.on('finish', () => resolve(filePath));
|
|
658
|
+
}
|
|
641
659
|
writeStream.on('error', reject);
|
|
642
660
|
});
|
|
643
661
|
}
|
|
@@ -710,20 +728,23 @@ async function streamParquet(filePath, data, options = {}) {
|
|
|
710
728
|
});
|
|
711
729
|
|
|
712
730
|
if (filePath?.startsWith('gs://')) {
|
|
713
|
-
// For GCS, write to buffer first, then upload
|
|
714
731
|
// @ts-ignore
|
|
715
732
|
const arrayBuffer = parquetWriteBuffer({ columnData });
|
|
716
733
|
const { bucket, file } = parseGCSUri(filePath);
|
|
717
|
-
|
|
718
|
-
const writeStream = storage.bucket(bucket).file(file).createWriteStream({
|
|
719
|
-
gzip: gzip || true // Always gzip for GCS
|
|
720
|
-
});
|
|
734
|
+
const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
|
|
721
735
|
|
|
722
736
|
return new Promise((resolve, reject) => {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
737
|
+
gcsStream.on('finish', () => resolve(filePath));
|
|
738
|
+
gcsStream.on('error', reject);
|
|
739
|
+
if (gzip) {
|
|
740
|
+
const gzipStream = zlib.createGzip();
|
|
741
|
+
gzipStream.pipe(gcsStream);
|
|
742
|
+
gzipStream.write(Buffer.from(arrayBuffer));
|
|
743
|
+
gzipStream.end();
|
|
744
|
+
} else {
|
|
745
|
+
gcsStream.write(Buffer.from(arrayBuffer));
|
|
746
|
+
gcsStream.end();
|
|
747
|
+
}
|
|
727
748
|
});
|
|
728
749
|
} else {
|
|
729
750
|
// For local files
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ak--47/dungeon-master",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"scripts/",
|
|
27
27
|
"package.json",
|
|
28
28
|
"README.md",
|
|
29
|
-
"CHANGELOG.md"
|
|
29
|
+
"CHANGELOG.md",
|
|
30
|
+
"HOOKS.md"
|
|
30
31
|
],
|
|
31
32
|
"engines": {
|
|
32
33
|
"node": ">=18.0.0"
|